feat(app): смена сервера API и очистка данных устройства при выходе
Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
@@ -23,11 +23,16 @@ class _ErrorCapture extends ErrorInterceptorHandler {
|
||||
void next(DioException error) => forwarded = error;
|
||||
}
|
||||
|
||||
RequestOptions _request(String method, String path, [Map<String, dynamic> query = const {}]) =>
|
||||
RequestOptions(path: path, method: method, queryParameters: query);
|
||||
RequestOptions _request(
|
||||
String method,
|
||||
String path, [
|
||||
Map<String, dynamic> query = const {},
|
||||
]) => RequestOptions(path: path, method: method, queryParameters: query);
|
||||
|
||||
DioException _offline(RequestOptions options) =>
|
||||
DioException(requestOptions: options, type: DioExceptionType.connectionError);
|
||||
DioException _offline(RequestOptions options) => DioException(
|
||||
requestOptions: options,
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late ResponseCacheDatabase db;
|
||||
@@ -44,7 +49,11 @@ void main() {
|
||||
final options = _request('GET', '/networth/breakdown');
|
||||
|
||||
interceptor.onResponse(
|
||||
Response<dynamic>(requestOptions: options, statusCode: 200, data: {'totalRub': '100'}),
|
||||
Response<dynamic>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: {'totalRub': '100'},
|
||||
),
|
||||
_ResponseCapture(),
|
||||
);
|
||||
await pumpEventQueue(); // the write is fire-and-forget
|
||||
@@ -53,17 +62,20 @@ void main() {
|
||||
expect(cached?.body, {'totalRub': '100'});
|
||||
});
|
||||
|
||||
test('a connection error is answered from the cache, with fetchedAt set', () async {
|
||||
final options = _request('GET', '/networth/breakdown');
|
||||
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||
test(
|
||||
'a connection error is answered from the cache, with fetchedAt set',
|
||||
() async {
|
||||
final options = _request('GET', '/networth/breakdown');
|
||||
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||
|
||||
final handler = _ErrorCapture();
|
||||
await interceptor.onError(_offline(options), handler);
|
||||
final handler = _ErrorCapture();
|
||||
await interceptor.onError(_offline(options), handler);
|
||||
|
||||
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
||||
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
||||
expect(handler.forwarded, isNull);
|
||||
});
|
||||
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
||||
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
||||
expect(handler.forwarded, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('a connection error with nothing cached still fails', () async {
|
||||
final options = _request('GET', '/networth/breakdown', {'never': 'asked'});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:fintracker_app/core/auth/auth_controller.dart';
|
||||
import 'package:fintracker_app/core/auth/switch_server.dart';
|
||||
import 'package:fintracker_app/core/auth/token_store.dart';
|
||||
import 'package:fintracker_app/core/cache/response_cache.dart';
|
||||
import 'package:fintracker_app/core/cache/response_cache_provider.dart';
|
||||
import 'package:fintracker_app/core/config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// No stored refresh token, so `logout()` never touches the network.
|
||||
class _NoTokenStore extends TokenStore {
|
||||
bool cleared = false;
|
||||
|
||||
@override
|
||||
Future<String?> readRefresh() async => null;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => cleared = true;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ResponseCacheDatabase db;
|
||||
late _NoTokenStore tokens;
|
||||
|
||||
ProviderContainer container({String? storedUrl}) {
|
||||
final c = ProviderContainer(
|
||||
overrides: [
|
||||
responseCacheDbProvider.overrideWithValue(db),
|
||||
tokenStoreProvider.overrideWithValue(tokens),
|
||||
initialApiBaseUrlProvider.overrideWithValue(storedUrl),
|
||||
],
|
||||
);
|
||||
addTearDown(c.dispose);
|
||||
return c;
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
db = ResponseCacheDatabase.forTesting(NativeDatabase.memory());
|
||||
tokens = _NoTokenStore();
|
||||
await db.put('/networth/breakdown', {'totalRub': '100'});
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('logout drops the token and every cached response', () async {
|
||||
final c = container();
|
||||
|
||||
await c.read(authControllerProvider.notifier).logout();
|
||||
|
||||
expect(tokens.cleared, isTrue);
|
||||
expect(await db.get('/networth/breakdown'), isNull);
|
||||
expect(c.read(authControllerProvider).status, AuthStatus.signedOut);
|
||||
});
|
||||
|
||||
group('API base URL', () {
|
||||
test('a saved address wins over the default', () {
|
||||
expect(
|
||||
container(storedUrl: 'https://fin.example.com')
|
||||
.read(apiBaseUrlProvider),
|
||||
'https://fin.example.com',
|
||||
);
|
||||
expect(container().read(apiBaseUrlProvider), defaultApiBaseUrl());
|
||||
});
|
||||
|
||||
test('normalize accepts http(s) origins and trims the trailing slash', () {
|
||||
expect(
|
||||
ApiBaseUrlController.normalize(' https://fin.example.com/ '),
|
||||
'https://fin.example.com',
|
||||
);
|
||||
expect(
|
||||
ApiBaseUrlController.normalize('http://10.0.0.5:8000'),
|
||||
'http://10.0.0.5:8000',
|
||||
);
|
||||
expect(ApiBaseUrlController.normalize('fin.example.com'), isNull);
|
||||
expect(ApiBaseUrlController.normalize('ftp://fin.example.com'), isNull);
|
||||
expect(ApiBaseUrlController.normalize(''), isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'switching servers signs out, clears the cache and persists the address',
|
||||
() async {
|
||||
final c = container();
|
||||
|
||||
await switchApiBaseUrl(c, 'https://other.example.com');
|
||||
|
||||
expect(c.read(apiBaseUrlProvider), 'https://other.example.com');
|
||||
expect(await loadStoredApiBaseUrl(), 'https://other.example.com');
|
||||
expect(tokens.cleared, isTrue);
|
||||
expect(await db.get('/networth/breakdown'), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'saving the address already in use keeps the session and the cache',
|
||||
() async {
|
||||
final c = container(storedUrl: 'https://fin.example.com');
|
||||
|
||||
await switchApiBaseUrl(c, 'https://fin.example.com');
|
||||
|
||||
expect(tokens.cleared, isFalse);
|
||||
expect(await db.get('/networth/breakdown'), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'null goes back to the default and forgets the saved address',
|
||||
() async {
|
||||
final c = container(storedUrl: 'https://fin.example.com');
|
||||
|
||||
await switchApiBaseUrl(c, null);
|
||||
|
||||
expect(c.read(apiBaseUrlProvider), defaultApiBaseUrl());
|
||||
expect(await loadStoredApiBaseUrl(), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/api/common_providers.dart';
|
||||
import 'package:fintracker_app/features/settings/settings_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
Future<void> _pump(WidgetTester tester, double width) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
meProvider.overrideWith(
|
||||
(ref) async => UserOut(email: 'ada@example.com', id: 1),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: Scaffold(body: SettingsPage())),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('tabs along the top, the tab\'s sections in a menu on the left', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, 1200);
|
||||
|
||||
for (final tab in ['Аккаунт', 'Отображение', 'Сервер']) {
|
||||
expect(find.text(tab), findsOneWidget, reason: tab);
|
||||
}
|
||||
// the first tab opens on its first section
|
||||
expect(find.text('Приватные данные'), findsOneWidget);
|
||||
expect(find.text('Безопасность'), findsOneWidget);
|
||||
expect(find.text('ada@example.com'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Безопасность'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Выйти'), findsOneWidget);
|
||||
expect(find.text('ada@example.com'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('switching the tab resets to its first section', (tester) async {
|
||||
await _pump(tester, 1200);
|
||||
|
||||
await tester.tap(find.text('Безопасность'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Сервер'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Адрес API'), findsWidgets);
|
||||
expect(find.text('О приложении'), findsOneWidget);
|
||||
expect(find.text('Изменить'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Отображение'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Тёмная'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a narrow surface turns the menu into chips above the panel', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, 500);
|
||||
|
||||
expect(find.byType(ChoiceChip), findsNWidgets(2));
|
||||
expect(find.text('ada@example.com'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Безопасность'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Выйти'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user