fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案
This commit is contained in:
1 parent
6997a65c96
commit
d364c6d908
46 files changed
+2060
-395
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors;
|
||||
|
||||
void main() {
|
||||
test('classifies Kratos invalid token and preserves the raw body', () async {
|
||||
final body = errors.Status(
|
||||
code: 401,
|
||||
reason: 'INVALID_TOKEN',
|
||||
message: 'expired',
|
||||
).writeToBuffer();
|
||||
final dio = Dio()
|
||||
..httpClientAdapter = _ErrorAdapter(body, 401)
|
||||
..interceptors.add(KratosErrorInterceptor());
|
||||
|
||||
try {
|
||||
await dio.get<List<int>>(
|
||||
'/me',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
fail('request should fail');
|
||||
} on DioException catch (error) {
|
||||
final exception = ApiException.from(error);
|
||||
expect(exception.kind, ApiExceptionKind.backend);
|
||||
expect(exception.reason, 'INVALID_TOKEN');
|
||||
expect(exception.isUnauthorized, isTrue);
|
||||
expect(error.response!.data, body);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifies transport, retryable server errors, and cancellation', () {
|
||||
final request = RequestOptions(path: '/test');
|
||||
expect(
|
||||
ApiException.fromDio(DioException(requestOptions: request)).kind,
|
||||
ApiExceptionKind.transport,
|
||||
);
|
||||
expect(
|
||||
ApiException.fromDio(
|
||||
DioException(
|
||||
requestOptions: request,
|
||||
response: Response<void>(requestOptions: request, statusCode: 503),
|
||||
),
|
||||
).isRetryable,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApiException.fromDio(
|
||||
DioException(requestOptions: request, type: DioExceptionType.cancel),
|
||||
).kind,
|
||||
ApiExceptionKind.cancelled,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _ErrorAdapter implements HttpClientAdapter {
|
||||
_ErrorAdapter(this.body, this.status);
|
||||
|
||||
final List<int> body;
|
||||
final int status;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromBytes(body, status);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/auth_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('external absolute URLs lose all backend identity headers', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
),
|
||||
);
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
|
||||
..interceptors.add(
|
||||
AuthInterceptor(
|
||||
session,
|
||||
backendOrigin: Uri.parse('https://api.example.com'),
|
||||
),
|
||||
)
|
||||
..httpClientAdapter = _CaptureAdapter();
|
||||
|
||||
final response = await dio.get<void>(
|
||||
'https://cdn.example.net/ad.png',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer leaked',
|
||||
'X-Device-Id': 'device',
|
||||
'User-iOS-IDFA': 'idfa',
|
||||
'User-DeviceToken': 'push',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final headers = response.requestOptions.headers.keys
|
||||
.map((key) => key.toLowerCase())
|
||||
.toSet();
|
||||
expect(headers, isNot(contains('authorization')));
|
||||
expect(headers, isNot(contains('x-device-id')));
|
||||
expect(headers, isNot(contains('user-ios-idfa')));
|
||||
expect(headers, isNot(contains('user-devicetoken')));
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
final Map<String, String> values = {};
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
@override
|
||||
Future<String?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
}
|
||||
|
||||
class _CaptureAdapter implements HttpClientAdapter {
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromString('', 204);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/push/push_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Retrofit push client uses generated protobuf in both directions',
|
||||
() async {
|
||||
final adapter = _PushAdapter();
|
||||
final client = PushApiClient(
|
||||
Dio(BaseOptions(responseType: ResponseType.bytes))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
final reply = await client.registerDevice(
|
||||
pb.RegisterPushDeviceRequest(
|
||||
umengDeviceToken: 'push-token',
|
||||
platform: pb.Platform.PLATFORM_IOS,
|
||||
appVersion: '1.1.0',
|
||||
permissionGranted: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(reply.deviceRecordId, 'device-record');
|
||||
final request = pb.RegisterPushDeviceRequest.fromBuffer(adapter.body!);
|
||||
expect(request.umengDeviceToken, 'push-token');
|
||||
expect(request.platform, pb.Platform.PLATFORM_IOS);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PushAdapter implements HttpClientAdapter {
|
||||
List<int>? body;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
body = (options.data as List<int>).toList();
|
||||
return ResponseBody.fromBytes(
|
||||
pb.RegisterPushDeviceReply(
|
||||
deviceRecordId: 'device-record',
|
||||
).writeToBuffer(),
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/router/deep_link_service.dart';
|
||||
|
||||
void main() {
|
||||
setUp(DeepLinkService.resetForTest);
|
||||
|
||||
test('keeps FIFO events until the router is ready', () {
|
||||
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
|
||||
DeepLinkService.handle('jinzhi://market');
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news?id=1'));
|
||||
expect(DeepLinkService.pendingCount, 2);
|
||||
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(navigated, ['/market', '/news?id=1']);
|
||||
expect(DeepLinkService.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('rejects HTTPS links outside the exact trusted host allowlist', () {
|
||||
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
DeepLinkService.handleUri(Uri.parse('https://evil.example/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://sub.links.example.com/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com:8443/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
|
||||
|
||||
expect(navigated, ['/news']);
|
||||
expect(DeepLinkService.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('disables HTTPS deep links when no production host is configured', () {
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
|
||||
DeepLinkService.handle('jinzhi://news');
|
||||
|
||||
expect(navigated, ['/news']);
|
||||
});
|
||||
|
||||
test('deduplicates event ids even after the first event is consumed', () {
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
final payload = jsonEncode({
|
||||
'event_id': 'same-event',
|
||||
'deep_link': 'jinzhi://assets',
|
||||
});
|
||||
|
||||
DeepLinkService.handle(payload);
|
||||
DeepLinkService.handle(payload);
|
||||
|
||||
expect(navigated, ['/assets']);
|
||||
});
|
||||
|
||||
test('uses bounded queue and discards expired events', () {
|
||||
final old = DateTime.now().subtract(DeepLinkService.expiry * 2);
|
||||
DeepLinkService.enqueue(
|
||||
'jinzhi://assets',
|
||||
source: DeepLinkSource.appLink,
|
||||
receivedAt: old,
|
||||
);
|
||||
for (var index = 0; index < DeepLinkService.maxPending + 5; index++) {
|
||||
DeepLinkService.enqueue(
|
||||
'jinzhi://news?event_id=$index',
|
||||
source: DeepLinkSource.push,
|
||||
);
|
||||
}
|
||||
expect(DeepLinkService.pendingCount, DeepLinkService.maxPending);
|
||||
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
expect(navigated, hasLength(DeepLinkService.maxPending));
|
||||
expect(navigated, isNot(contains('/assets')));
|
||||
});
|
||||
|
||||
test('auth guard leaves the head pending until auth changes', () {
|
||||
var authenticated = false;
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
}, authGuard: (_) => authenticated);
|
||||
DeepLinkService.handle('jinzhi://profile');
|
||||
expect(DeepLinkService.pendingCount, 1);
|
||||
|
||||
authenticated = true;
|
||||
DeepLinkService.notifyAuthChanged();
|
||||
expect(navigated, ['/profile']);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
@@ -14,7 +17,10 @@ void main() {
|
||||
});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage();
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'legacy');
|
||||
expect(await secure.read('access_token'), 'legacy');
|
||||
@@ -24,17 +30,109 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'migrates the previous secure jwt key when no current token exists',
|
||||
'rejects an orphaned Keychain token when the install marker is absent',
|
||||
() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({'jwt_token': 'secure-legacy'});
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'secure-legacy');
|
||||
expect(await secure.read('access_token'), 'secure-legacy');
|
||||
expect(await storage.restoreAccessToken(), isNull);
|
||||
expect(await secure.read('access_token'), isNull);
|
||||
expect(await secure.read('jwt_token'), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('restores a secure token only with the installation marker', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({secureAccessToken: 'active'});
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'active');
|
||||
});
|
||||
|
||||
test('logout clears session secrets but preserves device identity', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({
|
||||
secureAccessToken: 'access',
|
||||
secureMallCredential: 'mall',
|
||||
secureRefreshToken: 'refresh',
|
||||
secureBiometricCredential: 'biometric',
|
||||
secureDeviceId: 'device',
|
||||
});
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
await storage.clearSessionCredentials();
|
||||
|
||||
expect(await secure.read(secureAccessToken), isNull);
|
||||
expect(await secure.read(secureMallCredential), isNull);
|
||||
expect(await secure.read(secureRefreshToken), isNull);
|
||||
expect(await secure.read(secureBiometricCredential), 'biometric');
|
||||
expect(await secure.read(secureDeviceId), 'device');
|
||||
|
||||
await storage.clearAccountCredentials();
|
||||
expect(await secure.read(secureBiometricCredential), isNull);
|
||||
expect(await secure.read(secureDeviceId), 'device');
|
||||
});
|
||||
|
||||
test(
|
||||
'serializes restore with logout so an old token cannot revive',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _BlockingReadSecureStorage({
|
||||
secureAccessToken: 'old-token',
|
||||
});
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
),
|
||||
);
|
||||
|
||||
final restoring = session.restore();
|
||||
await secure.readStarted.future;
|
||||
final clearing = session.clear();
|
||||
secure.releaseRead.complete();
|
||||
|
||||
expect(await restoring, 'old-token');
|
||||
await clearing;
|
||||
expect(session.accessToken, isNull);
|
||||
expect(await secure.read(secureAccessToken), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('serializes restore with login so the new token wins', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _BlockingReadSecureStorage({secureAccessToken: 'old-token'});
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
),
|
||||
);
|
||||
|
||||
final restoring = session.restore();
|
||||
await secure.readStarted.future;
|
||||
final saving = session.save('new-token');
|
||||
secure.releaseRead.complete();
|
||||
|
||||
expect(await restoring, 'old-token');
|
||||
await saving;
|
||||
expect(session.accessToken, 'new-token');
|
||||
expect(await secure.read(secureAccessToken), 'new-token');
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
@@ -54,3 +152,21 @@ class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockingReadSecureStorage extends _MemorySecureStorage {
|
||||
_BlockingReadSecureStorage(super.initial);
|
||||
|
||||
final readStarted = Completer<void>();
|
||||
final releaseRead = Completer<void>();
|
||||
var _blocked = false;
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
if (key == secureAccessToken && !_blocked) {
|
||||
_blocked = true;
|
||||
readStarted.complete();
|
||||
await releaseRead.future;
|
||||
}
|
||||
return super.read(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Retrofit client decodes generated ad protobuf and encodes events',
|
||||
() async {
|
||||
final adapter = _AdsAdapter();
|
||||
final backend = AdsBackendApiClient(
|
||||
Dio(BaseOptions(responseType: ResponseType.bytes))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
final client = AdsApiClient(backend);
|
||||
|
||||
final result = await client.getAdResult(splashAdPlacement);
|
||||
expect(result.enabled, isTrue);
|
||||
expect(result.ad?.title, '商城');
|
||||
expect(result.ad?.endAtMs, 4102444800000);
|
||||
|
||||
await client.report(result.ad!, 'impression');
|
||||
final report = pb.ReportAdEventRequest.fromBuffer(adapter.lastBody!);
|
||||
expect(report.eventName, 'impression');
|
||||
expect(report.adId, 'ad-1');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _AdsAdapter implements HttpClientAdapter {
|
||||
List<int>? lastBody;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (options.path == '/api/v1/ads') {
|
||||
return ResponseBody.fromBytes(
|
||||
pb.GetAdReply(
|
||||
enabled: true,
|
||||
dailyCap: 1,
|
||||
ad: pb.AdCreative(
|
||||
adId: 'ad-1',
|
||||
placement: splashAdPlacement,
|
||||
title: '商城',
|
||||
imageUrl: 'https://cdn.example.com/ad.png',
|
||||
h5Url: 'https://example.com',
|
||||
trackedUrl: 'https://example.com',
|
||||
mediaType: pb.MediaType.MEDIA_TYPE_IMAGE,
|
||||
openMode: pb.AdOpenMode.AD_OPEN_MODE_EXTERNAL,
|
||||
endAtMs: Int64(4102444800000),
|
||||
),
|
||||
).writeToBuffer(),
|
||||
200,
|
||||
);
|
||||
}
|
||||
lastBody = (options.data as List<int>).toList();
|
||||
return ResponseBody.fromBytes(pb.ReportAdEventReply().writeToBuffer(), 200);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/protobuf_wire_reader.dart';
|
||||
|
||||
void main() {
|
||||
test('protobuf strings are decoded as UTF-8', () {
|
||||
final value = utf8.encode('商城');
|
||||
final message = ProtoMessage(<int>[10, value.length, ...value]);
|
||||
final message = ProtobufWireMessage(<int>[10, value.length, ...value]);
|
||||
|
||||
expect(message.string(1), '商城');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('account deletion stays empty after repository recreation', () async {
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final repository = MockAssetRepository(preferences);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
final initial = repository.loadHoldings(accountScope);
|
||||
expect(initial, isNotEmpty);
|
||||
expect(
|
||||
repository.loadHoldings(MockAssetRepository.guestAccountScope),
|
||||
isNotEmpty,
|
||||
);
|
||||
await repository.saveHoldings(accountScope, initial);
|
||||
|
||||
await repository.deleteAccountHoldings(accountScope);
|
||||
final recreated = MockAssetRepository(preferences);
|
||||
|
||||
expect(recreated.loadHoldings(accountScope), isEmpty);
|
||||
expect(
|
||||
recreated.loadHoldings(MockAssetRepository.guestAccountScope),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('holdings are isolated by account and deletion is scoped', () async {
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final repository = MockAssetRepository(preferences);
|
||||
final first = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
final second = MockAssetRepository.accountScope(userId: 'user-2');
|
||||
final firstHoldings = repository.loadHoldings(first);
|
||||
final secondHoldings = repository.loadHoldings(second);
|
||||
await repository.saveHoldings(first, firstHoldings.take(1).toList());
|
||||
await repository.saveHoldings(second, secondHoldings.take(2).toList());
|
||||
|
||||
await repository.deleteAccountHoldings(first);
|
||||
|
||||
expect(repository.loadHoldings(first), isEmpty);
|
||||
expect(repository.loadHoldings(second), hasLength(2));
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_controller.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/data/auth_models.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -61,7 +66,7 @@ void main() {
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
expect(fixture.controller.state.value, isA<GuestAuthState>());
|
||||
expect(fixture.controller.state.value, isA<SessionExpiredAuthState>());
|
||||
expect(await fixture.identity.readJwt(), isNull);
|
||||
});
|
||||
|
||||
@@ -81,6 +86,59 @@ void main() {
|
||||
expect(fixture.preferences.containsKey('jinzhi_mock_logged_in'), isFalse);
|
||||
});
|
||||
|
||||
test('restores cached profile before the background profile reply', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString(storageUserId, 'cached-user');
|
||||
await fixture.preferences.setString(storageUserPhone, '13900139000');
|
||||
fixture.profileCompleter = Completer<void>();
|
||||
|
||||
final bootstrap = fixture.controller.retryBootstrap();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final stale = fixture.controller.state.value! as LoggedInAuthState;
|
||||
expect(stale.user.id, 'cached-user');
|
||||
expect(stale.isVerified, isFalse);
|
||||
|
||||
fixture.profileCompleter!.complete();
|
||||
await bootstrap;
|
||||
expect(
|
||||
(fixture.controller.state.value! as LoggedInAuthState).isVerified,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps cached login on 403, 429, 5xx, and decode failures', () async {
|
||||
for (final failure in [403, 429, 503, -1]) {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString(storageUserId, 'cached-user');
|
||||
fixture.profileFailureStatus = failure;
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
expect(fixture.controller.state.value, isA<LoggedInAuthState>());
|
||||
expect(await fixture.identity.readJwt(), 'token');
|
||||
}
|
||||
});
|
||||
|
||||
test('logout preserves assets while account deletion removes them', () async {
|
||||
final logoutFixture = await _Fixture.create();
|
||||
await logoutFixture.identity.saveJwt('token');
|
||||
await logoutFixture.preferences.setString(storageJinzhiHoldings, 'assets');
|
||||
await logoutFixture.controller.logout();
|
||||
expect(
|
||||
logoutFixture.preferences.getString(storageJinzhiHoldings),
|
||||
'assets',
|
||||
);
|
||||
|
||||
final deleteFixture = await _Fixture.create();
|
||||
await deleteFixture.identity.saveJwt('token');
|
||||
await deleteFixture.preferences.setString(storageJinzhiHoldings, 'assets');
|
||||
await deleteFixture.controller.deleteAccount('123456');
|
||||
expect(deleteFixture.preferences.getString(storageJinzhiHoldings), isNull);
|
||||
});
|
||||
|
||||
test('keeps the verified one-key login state shape', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
|
||||
@@ -106,20 +164,47 @@ class _Fixture {
|
||||
bool failDeletion = false;
|
||||
bool failProfile = false;
|
||||
bool profileUnauthorized = false;
|
||||
int? profileFailureStatus;
|
||||
Completer<void>? profileCompleter;
|
||||
String? deletedWithCode;
|
||||
|
||||
static Future<_Fixture> create() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
late _Fixture fixture;
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
onRequest: (options, handler) async {
|
||||
if (options.path == '/api/v1/me' && options.method == 'GET') {
|
||||
if (fixture.profileCompleter case final completer?) {
|
||||
await completer.future;
|
||||
}
|
||||
if (fixture.profileFailureStatus case final status?) {
|
||||
if (status == -1) {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: const [255],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: Response<void>(
|
||||
requestOptions: options,
|
||||
statusCode: status,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (fixture.failProfile) {
|
||||
handler.reject(DioException(requestOptions: options));
|
||||
return;
|
||||
@@ -185,10 +270,17 @@ class _Fixture {
|
||||
},
|
||||
),
|
||||
);
|
||||
final preferencesStorage = PreferencesStorage(preferences);
|
||||
final sessionStorage = AuthSessionStorage(identity);
|
||||
final coordinator = AuthSessionCoordinator(
|
||||
sessionStorage,
|
||||
preferencesStorage,
|
||||
MockAssetRepository(preferencesStorage),
|
||||
);
|
||||
final controller = AuthController(
|
||||
dio,
|
||||
identityStorage: identity,
|
||||
preferences: preferences,
|
||||
preferences: preferencesStorage,
|
||||
coordinator: coordinator,
|
||||
autoBootstrap: false,
|
||||
);
|
||||
fixture = _Fixture(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'concurrent unauthorized responses expire one session only once',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final assets = MockAssetRepository(preferences);
|
||||
final coordinator = AuthSessionCoordinator(session, preferences, assets);
|
||||
await coordinator.startSession('token');
|
||||
final generation = coordinator.generation;
|
||||
var notifications = 0;
|
||||
coordinator.addListener((_) => notifications++);
|
||||
final request = RequestOptions(
|
||||
path: '/protected',
|
||||
extra: {authSessionGenerationExtra: generation},
|
||||
);
|
||||
final exception = ApiException(
|
||||
kind: ApiExceptionKind.backend,
|
||||
httpStatus: 400,
|
||||
reason: 'TOKEN_EXPIRED',
|
||||
requestOptions: request,
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
coordinator.expireIfNeeded(exception),
|
||||
coordinator.expireIfNeeded(exception),
|
||||
]);
|
||||
|
||||
expect(notifications, 1);
|
||||
expect(await identity.readJwt(), isNull);
|
||||
expect(await coordinator.expireIfNeeded(exception), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('account deletion upgrades an in-flight expiration', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
storageUserId: 'user-1',
|
||||
storageUserPhone: '13800138000',
|
||||
storageJinzhiHoldings: 'legacy-assets',
|
||||
});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final secure = _MemorySecureStorage({
|
||||
secureBiometricCredential: 'biometric',
|
||||
});
|
||||
final identity = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final assets = MockAssetRepository(preferences);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope));
|
||||
final coordinator = AuthSessionCoordinator(session, preferences, assets);
|
||||
await coordinator.startSession('token');
|
||||
final notifications = <SessionEndReason>[];
|
||||
coordinator.addListener(notifications.add);
|
||||
|
||||
final expiring = coordinator.end(SessionEndReason.expired);
|
||||
final deleting = coordinator.accountDeleted();
|
||||
await Future.wait([expiring, deleting]);
|
||||
|
||||
expect(notifications, [SessionEndReason.accountDeleted]);
|
||||
expect(await secure.read(secureBiometricCredential), isNull);
|
||||
expect(assets.loadHoldings(accountScope), isEmpty);
|
||||
expect(preferences.containsKey(storageJinzhiHoldings), isFalse);
|
||||
});
|
||||
|
||||
test('a new login waits for an in-flight session end', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final coordinator = AuthSessionCoordinator(
|
||||
session,
|
||||
preferences,
|
||||
MockAssetRepository(preferences),
|
||||
);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
await preferences.setString(storageUserId, 'user-1');
|
||||
final assets = MockAssetRepository(preferences);
|
||||
await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope));
|
||||
await coordinator.startSession('old-token');
|
||||
|
||||
final ending = coordinator.logout();
|
||||
final starting = coordinator.startSession('new-token');
|
||||
await Future.wait([ending, starting]);
|
||||
|
||||
expect(session.accessToken, 'new-token');
|
||||
expect(await identity.readJwt(), 'new-token');
|
||||
expect(assets.loadHoldings(accountScope), isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_MemorySecureStorage([Map<String, String>? initial]) : values = {...?initial};
|
||||
|
||||
final Map<String, String> values;
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
@override
|
||||
Future<String?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/mall/application/mall_entry_promo_store.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test('records each first-install mall promotion exposure once', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = MallEntryPromoStore(await SharedPreferences.getInstance());
|
||||
final store = MallEntryPromoStore(
|
||||
PreferencesStorage(await SharedPreferences.getInstance()),
|
||||
);
|
||||
|
||||
expect(store.hasOpenedMall, isFalse);
|
||||
expect(store.hasSeenHomeBadge, isFalse);
|
||||
|
||||
Reference in new issue
Block a user