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,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