// routing_provider_test.dart — 分流档案 provider:乐观更新 + 保存调用 + 失败回退。 import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/models/routing_profile.dart'; import 'package:pangolin_vpn/services/account_api.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/token_store.dart'; import 'package:pangolin_vpn/state/account_providers.dart'; import 'package:pangolin_vpn/state/auth_provider.dart'; import 'package:pangolin_vpn/state/routing_provider.dart'; /// 已登录的桩 TokenStore(无平台依赖,仿 nodes_provider_test 的 _NullTokenStore)。 class _LoggedInTokenStore implements TokenStore { const _LoggedInTokenStore(); @override Future saveTokens({required String access, required String refresh}) async {} @override Future loadAccessToken() async => 'test-token'; @override Future loadRefreshToken() async => 'test-refresh'; @override Future clear() async {} @override Future markOnboarded() async {} @override Future isOnboarded() async => true; @override Future saveLastEmail(String email) async {} @override Future loadLastEmail() async => null; } /// 轮询等 authProvider 的异步 _loadFromStore() 落地已登录态。 Future _waitLoggedIn(ProviderContainer c) async { for (var i = 0; i < 60; i++) { if (c.read(authProvider).isLoggedIn) return; await Future.delayed(const Duration(milliseconds: 10)); } throw StateError('authProvider 未在预期时间内进入已登录态'); } /// 假 AccountApi:只桩 routingProfile()/saveRoutingProfile(),记录调用便于断言。 class _FakeAccountApi extends AccountApi { _FakeAccountApi() : super(ApiClient( baseUrl: 'http://test.local', getToken: () => null, refresh: () async => false, )); RoutingProfile initial = const RoutingProfile(); final List saved = []; /// 非 null 时 saveRoutingProfile 抛此异常(用于测失败回退)。 Exception? saveError; @override Future routingProfile() async => initial; @override Future saveRoutingProfile(RoutingProfile p) async { final err = saveError; if (err != null) throw err; saved.add(p); } } ProviderContainer _makeContainer(_FakeAccountApi api) => ProviderContainer(overrides: [ tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), accountApiProvider.overrideWithValue(api), ]); void main() { test('addRule 乐观更新 state 并调用 saveRoutingProfile', () async { final api = _FakeAccountApi(); final c = _makeContainer(api); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); // 等初次加载 const rule = RoutingRule(type: 'domain_suffix', value: 'x.com', action: 'direct'); await c.read(routingProfileProvider.notifier).addRule(rule); final state = c.read(routingProfileProvider).value!; expect(state.rules, hasLength(1)); expect(state.rules.single.value, 'x.com'); expect(state.rules.single.action, 'direct'); expect(api.saved, hasLength(1), reason: '应调用 saveRoutingProfile 落盘'); expect(api.saved.single.rules.single.value, 'x.com'); }); test('removeRule / setMode / reorder 均乐观更新 + 落盘', () async { final api = _FakeAccountApi() ..initial = const RoutingProfile(rules: [ RoutingRule(type: 'domain', value: 'a.com', action: 'direct'), RoutingRule(type: 'domain', value: 'b.com', action: 'proxy'), ]); final c = _makeContainer(api); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); await c.read(routingProfileProvider.notifier).setMode('global'); expect(c.read(routingProfileProvider).value!.mode, 'global'); await c.read(routingProfileProvider.notifier).reorder(0, 2); expect(c.read(routingProfileProvider).value!.rules.map((r) => r.value).toList(), ['b.com', 'a.com']); await c.read(routingProfileProvider.notifier).removeRule(0); expect(c.read(routingProfileProvider).value!.rules.single.value, 'a.com'); expect(api.saved, hasLength(3)); }); test('resetToDefault() 还原出厂档案,保留 systemLockedDomains,并落盘', () async { final api = _FakeAccountApi() ..initial = RoutingProfile.fromJson({ 'mode': 'global', 'builtin': {'china_direct': false, 'lan_direct': true, 'private_via_tunnel': true}, 'rules': [ {'type': 'domain', 'value': 'a.com', 'action': 'reject'}, ], 'final': 'direct', 'system_locked_domains': ['git.yanmeiai.com'], }); final c = _makeContainer(api); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); await c.read(routingProfileProvider.notifier).resetToDefault(); final state = c.read(routingProfileProvider).value!; expect(state.mode, 'rule'); expect(state.builtin.chinaDirect, isTrue); expect(state.rules, isEmpty); expect(state.finalAction, 'proxy'); expect(state.systemLockedDomains, ['git.yanmeiai.com'], reason: '只读字段随重置保留,不因重置默认档案而丢失'); expect(api.saved, hasLength(1)); expect(api.saved.single.mode, 'rule'); }); test('保存失败:回退到变更前 state 且 rethrow,不静默吞', () async { final api = _FakeAccountApi(); final c = _makeContainer(api); addTearDown(c.dispose); await _waitLoggedIn(c); await c.read(routingProfileProvider.future); final before = c.read(routingProfileProvider).value!; api.saveError = Exception('network down'); const rule = RoutingRule(type: 'domain', value: 'fail.com', action: 'proxy'); await expectLater( c.read(routingProfileProvider.notifier).addRule(rule), throwsA(isA()), ); // 不静默吞:异常已通过 expectLater 的 throwsA 断言被抛出给调用方。 final after = c.read(routingProfileProvider); expect(after.hasError, isFalse, reason: '失败走「整体回退」而非挂错误态'); expect(after.value, before, reason: '应回退到变更前 state'); expect(api.saved, isEmpty); }); }