Files
FlClash/test/providers/service_status_test.dart
chen08209 8068e686a3 feat(core): check services and the outbound IP through the Core
Changelog: Proxied service checks and outbound IP risk
2026-10-02 02:51:32 +08:00

314 lines
10 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/common/service_probe.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/route_state.dart';
import 'package:fl_clash/providers/routed_probe.dart';
import 'package:fl_clash/providers/service_status.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class _MockCore extends Mock implements CoreHandlerInterface {}
ServiceCheckItem _item(String name, {String status = 'available'}) =>
ServiceCheckItem(
name: name,
status: status,
delay: 12,
chains: const ['HK-01'],
checkedAt: 1700000000000,
coreEpoch: 1,
picksVersion: 1,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => registerFallbackValue(const ServiceCheckParams(timeout: 0)));
late _MockCore core;
late List<ServiceCheckParams> sweeps;
setUp(() {
core = _MockCore();
sweeps = [];
when(() => core.serviceCheck(any())).thenAnswer((invocation) async {
final params =
invocation.positionalArguments.single as ServiceCheckParams;
sweeps.add(params);
return [for (final name in params.names) _item(name)];
});
});
ProviderContainer container({bool proxied = true, bool synced = true}) {
final scope = ProviderContainer(
overrides: [
routeTrackerProvider.overrideWithBuild(
(_, _) => RouteState(
coreEpoch: 1,
picksVersion: 1,
hostEpoch: 1,
proxied: proxied,
synced: synced,
live: synced,
),
),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
addTearDown(scope.dispose);
scope.listen(serviceStatusProvider, (_, _) {});
return scope;
}
ServiceStatus notifierOf(ProviderContainer scope) =>
scope.read(serviceStatusProvider.notifier);
RoutedProbeState<ServiceTarget, ServiceCheck> stateOf(
ProviderContainer scope,
) => scope.read(serviceStatusProvider);
test(
'checks each watched target through the Core route on its own',
() async {
final scope = container();
notifierOf(scope)
..watch(ServiceTarget.youtube)
..watch(ServiceTarget.claude);
await pumpEventQueue();
expect(sweeps.map((sweep) => sweep.names), [
['youtube'],
['claude'],
]);
expect(sweeps.first.proxyName, isEmpty);
expect(sweeps.first.timeout, probeTimeoutDuration.inMilliseconds);
final state = stateOf(scope);
expect(state.entryOf(ServiceTarget.youtube).phase, ProbePhase.fresh);
expect(state.valueOf(ServiceTarget.youtube)?.node, 'HK-01');
expect(state.valueOf(ServiceTarget.claude)?.delay, 12);
},
);
test('shows each answer as it lands', () async {
final scope = container();
final gates = <String, Completer<List<ServiceCheckItem>>>{};
when(() => core.serviceCheck(any())).thenAnswer((invocation) {
final params =
invocation.positionalArguments.single as ServiceCheckParams;
return (gates[params.names.single] = Completer()).future;
});
notifierOf(scope).refresh(ServiceTarget.values);
await pumpEventQueue();
expect(gates.keys, [for (final target in ServiceTarget.values) target.id]);
gates['google']!.complete([_item('google')]);
await pumpEventQueue();
final state = stateOf(scope);
expect(state.entryOf(ServiceTarget.google).phase, ProbePhase.fresh);
expect(state.isLoading(ServiceTarget.github), isTrue);
});
test(
'a route change asks the Core again at once, not behind the stale checks',
() async {
final scope = container();
when(() => core.serviceCheck(any())).thenAnswer((invocation) {
sweeps.add(invocation.positionalArguments.single as ServiceCheckParams);
return Completer<List<ServiceCheckItem>>().future;
});
final notifier = notifierOf(scope);
ServiceTarget.values.forEach(notifier.watch);
await pumpEventQueue();
expect(sweeps, hasLength(ServiceTarget.values.length));
scope.read(routeTrackerProvider.notifier).bumpHostEpoch();
await pumpEventQueue();
expect(sweeps.skip(ServiceTarget.values.length).map((s) => s.names), [
for (final target in ServiceTarget.values) [target.id],
]);
},
);
test('checks through the direct outbound while the proxy is off', () async {
final scope = container(proxied: false);
notifierOf(scope).watch(ServiceTarget.bilibili);
await pumpEventQueue();
expect(sweeps.single.proxyName, directOutbound);
expect(sweeps.single.names, ['bilibili']);
});
test('does not ask the Core before the route has been read', () async {
final scope = container(synced: false);
notifierOf(scope).watch(ServiceTarget.bilibili);
await pumpEventQueue();
verifyNever(() => core.serviceCheck(any()));
expect(stateOf(scope).isLoading(ServiceTarget.bilibili), isFalse);
});
test('marks a target loading while its check is in flight', () async {
final scope = container();
final gate = Completer<List<ServiceCheckItem>>();
when(() => core.serviceCheck(any())).thenAnswer((_) => gate.future);
notifierOf(scope).watch(ServiceTarget.youtube);
await pumpEventQueue();
expect(stateOf(scope).isLoading(ServiceTarget.youtube), isTrue);
gate.complete([_item('youtube')]);
await pumpEventQueue();
expect(stateOf(scope).isLoading(ServiceTarget.youtube), isFalse);
});
test(
'will not queue a second check for a target already in flight',
() async {
final scope = container();
final gate = Completer<List<ServiceCheckItem>>();
when(() => core.serviceCheck(any())).thenAnswer((_) => gate.future);
final notifier = notifierOf(scope);
notifier.watch(ServiceTarget.youtube);
await pumpEventQueue();
notifier
..watch(ServiceTarget.youtube)
..refresh([ServiceTarget.youtube]);
await pumpEventQueue();
gate.complete([_item('youtube')]);
await pumpEventQueue();
verify(() => core.serviceCheck(any())).called(1);
},
);
test('a refresh checks the named targets, watched or not', () async {
final scope = container();
final notifier = notifierOf(scope);
notifier.watch(ServiceTarget.youtube);
await pumpEventQueue();
notifier.refresh([ServiceTarget.youtube, ServiceTarget.claude]);
await pumpEventQueue();
expect(sweeps.map((sweep) => sweep.names), [
['youtube'],
['youtube'],
['claude'],
]);
expect(stateOf(scope).valueOf(ServiceTarget.claude), isNotNull);
});
test(
'a timeout is shown as a status and retried once the app resumes',
() async {
when(() => core.serviceCheck(any())).thenAnswer((invocation) async {
final params =
invocation.positionalArguments.single as ServiceCheckParams;
sweeps.add(params);
return [
for (final name in params.names) _item(name, status: 'timeout'),
];
});
final scope = container();
notifierOf(scope).watch(ServiceTarget.youtube);
await pumpEventQueue();
final entry = stateOf(scope).entryOf(ServiceTarget.youtube);
expect(entry.phase, ProbePhase.failed);
expect(entry.value?.status, ServiceProbeStatus.timeout);
scope.read(routeTrackerProvider.notifier).markResumed();
await pumpEventQueue();
expect(sweeps, hasLength(2));
},
);
test('drops results once the route has changed under them', () async {
final scope = container();
notifierOf(scope).watch(ServiceTarget.youtube);
await pumpEventQueue();
expect(stateOf(scope).valueOf(ServiceTarget.youtube), isNotNull);
scope.read(routeTrackerProvider.notifier).bumpHostEpoch();
expect(stateOf(scope).valueOf(ServiceTarget.youtube), isNull);
expect(stateOf(scope).isLoading(ServiceTarget.youtube), isTrue);
await pumpEventQueue();
expect(sweeps, hasLength(2));
expect(stateOf(scope).valueOf(ServiceTarget.youtube), isNotNull);
});
test(
'an answer that arrives after the route changed is asked again',
() async {
final scope = container();
final gate = Completer<List<ServiceCheckItem>>();
var first = true;
when(() => core.serviceCheck(any())).thenAnswer((invocation) {
final params =
invocation.positionalArguments.single as ServiceCheckParams;
sweeps.add(params);
if (first) {
first = false;
return gate.future;
}
return Future.value([for (final name in params.names) _item(name)]);
});
notifierOf(scope).watch(ServiceTarget.youtube);
await pumpEventQueue();
scope.read(routeTrackerProvider.notifier).bumpHostEpoch();
gate.complete([_item('youtube')]);
await pumpEventQueue();
expect(sweeps, hasLength(2));
expect(
stateOf(scope).entryOf(ServiceTarget.youtube).phase,
ProbePhase.fresh,
);
},
);
test(
'enabled targets follow the saved order and skip turned-off services',
() {
final scope = container();
scope.read(appSettingProvider.notifier).value = const AppSettingProps(
serviceOrder: ['claude', 'google'],
disabledServices: ['github', 'bilibili'],
);
expect(scope.read(enabledServiceTargetsProvider).map((t) => t.id), [
'claude',
'google',
for (final target in ServiceTarget.values)
if (!['claude', 'google', 'github', 'bilibili'].contains(target.id))
target.id,
]);
},
);
test('a config that turns every service off still offers them all', () {
final scope = container();
scope.read(appSettingProvider.notifier).value = AppSettingProps(
disabledServices: [for (final target in ServiceTarget.values) target.id],
);
expect(scope.read(enabledServiceTargetsProvider), ServiceTarget.values);
});
}