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'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => SharedPreferences.setMockInitialValues({})); test('restores a token by loading the real profile', () async { final fixture = await _Fixture.create(); await fixture.identity.saveJwt('token'); await fixture.controller.retryBootstrap(); final auth = fixture.controller.state.value! as LoggedInAuthState; expect(auth.token, 'token'); expect(auth.user.id, 'user-1'); expect(auth.user.phone, '13800138000'); expect(fixture.preferences.getString(storageUserId), 'user-1'); }); test('deletes the server account before clearing local identity', () async { final fixture = await _Fixture.create(); await fixture.identity.saveJwt('token'); await fixture.controller.retryBootstrap(); await fixture.controller.deleteAccount('123456'); expect(fixture.deletedWithCode, '123456'); expect(fixture.controller.state.value, isA()); expect(await fixture.identity.readJwt(), isNull); expect(fixture.preferences.getString(storageUserId), isNull); }); test('keeps the local session when server deletion fails', () async { final fixture = await _Fixture.create(); await fixture.identity.saveJwt('token'); await fixture.controller.retryBootstrap(); fixture.failDeletion = true; await expectLater( fixture.controller.deleteAccount('123456'), throwsA(isA()), ); expect(fixture.controller.state.value, isA()); expect(await fixture.identity.readJwt(), 'token'); expect(fixture.preferences.getString(storageUserId), 'user-1'); }); test('clears an unauthorized restored session', () async { final fixture = await _Fixture.create(); await fixture.identity.saveJwt('expired'); fixture.profileUnauthorized = true; await fixture.controller.retryBootstrap(); expect(fixture.controller.state.value, isA()); expect(await fixture.identity.readJwt(), isNull); }); test('keeps a valid token and cached profile on a network failure', () async { final fixture = await _Fixture.create(); await fixture.identity.saveJwt('token'); await fixture.preferences.setString('jinzhi_mock_logged_in', '13900139000'); fixture.failProfile = true; await fixture.controller.retryBootstrap(); final auth = fixture.controller.state.value! as LoggedInAuthState; expect(auth.user.id, isEmpty); expect(auth.user.phone, '13900139000'); expect(await fixture.identity.readJwt(), 'token'); expect(fixture.preferences.getString(storageUserPhone), '13900139000'); 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(); final bootstrap = fixture.controller.retryBootstrap(); await Future.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()); 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(); await fixture.controller.verifyOneKeyLogin('umeng-token'); final auth = fixture.controller.state.value! as LoggedInAuthState; expect(auth.token, 'one-key-token'); expect(auth.user.phone, '13800138000'); expect(await fixture.identity.readJwt(), 'one-key-token'); }); } class _Fixture { _Fixture({ required this.preferences, required this.identity, required this.controller, }); final SharedPreferences preferences; final SecureIdentityStorage identity; final AuthController controller; bool failDeletion = false; bool failProfile = false; bool profileUnauthorized = false; int? profileFailureStatus; Completer? profileCompleter; String? deletedWithCode; static Future<_Fixture> create() async { final preferences = await SharedPreferences.getInstance(); final identity = SecureIdentityStorage( PreferencesStorage(preferences), secureStorage: _MemorySecureStorage(), ); late _Fixture fixture; final dio = Dio() ..interceptors.add( InterceptorsWrapper( 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>( requestOptions: options, statusCode: 200, data: const [255], ), ); return; } handler.reject( DioException( requestOptions: options, response: Response( requestOptions: options, statusCode: status, ), ), ); return; } if (fixture.failProfile) { handler.reject(DioException(requestOptions: options)); return; } if (fixture.profileUnauthorized) { handler.reject( DioException( requestOptions: options, response: Response( requestOptions: options, statusCode: 401, ), ), ); return; } handler.resolve( Response>( requestOptions: options, statusCode: 200, data: pb.ProfileReply( user: pb.UserInfo( id: 'user-1', phone: '13800138000', createdAt: '2026-09-08T00:00:00Z', ), ).writeToBuffer(), ), ); return; } if (options.path == '/api/v1/me' && options.method == 'DELETE') { fixture.deletedWithCode = pb.DeleteAccountRequest.fromBuffer( options.data as List, ).smsCode; if (fixture.failDeletion) { handler.reject(DioException(requestOptions: options)); } else { handler.resolve( Response>( requestOptions: options, statusCode: 200, data: const [], ), ); } return; } if (options.path == '/api/v1/auth/onekey/verify') { handler.resolve( Response>( requestOptions: options, statusCode: 200, data: pb.VerifySmsCodeReply( accessToken: 'one-key-token', user: pb.UserInfo(phone: '13800138000'), ).writeToBuffer(), ), ); return; } handler.next(options); }, ), ); final preferencesStorage = PreferencesStorage(preferences); final sessionStorage = AuthSessionStorage(identity); final coordinator = AuthSessionCoordinator( sessionStorage, preferencesStorage, MockAssetRepository(preferencesStorage), ); final controller = AuthController( dio, preferences: preferencesStorage, coordinator: coordinator, autoBootstrap: false, ); fixture = _Fixture( preferences: preferences, identity: identity, controller: controller, ); return fixture; } } class _MemorySecureStorage implements SecureKeyValueStorage { final Map _values = {}; @override Future delete(String key) async => _values.remove(key); @override Future read(String key) async => _values[key]; @override Future write(String key, String value) async { _values[key] = value; } }