feat(app): смена сервера API и очистка данных устройства при выходе

Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
Dmitry
2026-09-19 22:14:14 +03:00
parent bfe74ca47c
commit a559d6de3e
17 changed files with 887 additions and 135 deletions
+26 -14
View File
@@ -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'});
+120
View File
@@ -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);
},
);
});
}
+75
View File
@@ -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);
});
}