Files
FlClash/test/common/migration_test.dart
T

424 lines
13 KiB
Dart
Raw Normal View History

2026-08-05 10:57:11 +08:00
import 'dart:convert';
import 'package:fl_clash/common/migration.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/enum/enum.dart';
2026-08-05 10:57:11 +08:00
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/ui/color.dart';
import 'package:material_ui/material_ui.dart' show ThemeMode;
2026-08-05 10:57:11 +08:00
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Migration', () {
test('returns current config without rewriting storage', () async {
final configMap = _createConfigMap(
davProps: const DAVProps(
uri: 'https://example.com/dav',
user: 'user',
password: 'secret',
),
);
final store = _FakeMigrationStore(
configMap: configMap,
version: Migration.currentVersion,
);
final config = await Migration(store: store).run();
expect(config, Config.realFromJson(configMap));
expect(config.davProps?.password, 'secret');
expect(store.events, ['getConfigMap', 'getVersion']);
});
test(
'obfuscates a compatible DAV password without a version migration',
() async {
final configMap = _createConfigMap(
davProps: const DAVProps(
uri: 'https://example.com/dav',
user: 'user',
),
);
final davProps = configMap['davProps']! as Map<String, Object?>;
davProps['password'] = 'secret';
final store = _FakeMigrationStore(configMap: configMap, version: 1);
final config = await Migration(store: store).run();
expect(config.davProps?.user, 'user');
expect(config.davProps?.password, 'secret');
expect(store.savedConfig, config);
expect(store.version, Migration.currentVersion);
expect(store.events, ['getConfigMap', 'getVersion', 'saveConfig']);
final savedConfigMap =
jsonDecode(jsonEncode(store.savedConfig)) as Map<String, Object?>;
final savedDavProps =
savedConfigMap['davProps']! as Map<String, Object?>;
expect(savedDavProps['password'], startsWith('v1.'));
expect(savedDavProps['password'], isNot(contains('secret')));
},
);
test(
'commits v0 cleanup and version only after migrated data is saved',
() async {
final configMap = <String, Object?>{
'proxiesStyle': <String, Object?>{},
'dav': <String, Object?>{
'uri': 'https://example.com/dav',
'user': 'user',
'password': 'secret',
},
};
final store = _FakeMigrationStore(
configMap: configMap,
version: 0,
clashConfigMap: <String, Object?>{'mixed-port': 7890},
);
final migration = Migration(
store: store,
migrateV0: (configMap) async {
store.events.add('migrateV0');
expect(configMap['patchClashConfig'], store.clashConfigMap);
return MigrationData(
configMap: _createConfigMap(
davProps: const DAVProps(
uri: 'https://example.com/dav',
user: 'user',
password: 'secret',
),
),
);
},
);
await migration.run();
expect(store.events, [
'getConfigMap',
'getVersion',
'getClashConfigMap',
'migrateV0',
'restore',
'saveConfig',
'clearClashConfig',
'setVersion',
]);
expect(store.savedConfig?.davProps?.password, 'secret');
expect(store.didClearClashConfig, isTrue);
expect(store.version, Migration.currentVersion);
},
);
test(
'preserves legacy clash config when current-shaped data has version zero',
() async {
final configMap = _createConfigMap()..remove('patchClashConfig');
final clashConfigMap = _createClashConfigMap(mixedPort: 1234);
final store = _FakeMigrationStore(
configMap: configMap,
version: 0,
clashConfigMap: clashConfigMap,
);
final config = await Migration(store: store).run();
expect(config.patchClashConfig.mixedPort, 1234);
expect(store.savedConfig?.patchClashConfig.mixedPort, 1234);
expect(store.didClearClashConfig, isTrue);
expect(store.events, [
'getConfigMap',
'getVersion',
'getClashConfigMap',
'restore',
'saveConfig',
'clearClashConfig',
'setVersion',
]);
},
);
test('does not clear legacy clash config when saving fails', () async {
final configMap = _createConfigMap()..remove('patchClashConfig');
final store = _FakeMigrationStore(
configMap: configMap,
version: 0,
clashConfigMap: _createClashConfigMap(mixedPort: 1234),
configSaveResult: false,
);
await expectLater(
Migration(store: store).run(),
throwsA(isA<StateError>()),
);
expect(store.didClearClashConfig, isFalse);
expect(store.version, 0);
expect(store.events, [
'getConfigMap',
'getVersion',
'getClashConfigMap',
'restore',
'saveConfig',
'isAvailable',
]);
});
test('starts with defaults when the store cannot be opened', () async {
final store = _FakeMigrationStore(
configMap: null,
version: 0,
configSaveResult: false,
available: false,
);
final config = await Migration(store: store).run();
expect(config, isNotNull);
expect(store.didClearClashConfig, isFalse);
expect(store.version, 0);
expect(store.events, [
'getConfigMap',
'getVersion',
'getClashConfigMap',
'restore',
'saveConfig',
'isAvailable',
2026-08-05 10:57:11 +08:00
]);
});
group('upgrade', () {
Map<String, Object?> customized() =>
_createConfigMap()
..['themeProps'] = {
'primaryColor': 0xFF123456,
'primaryColors': [0xFF795548, 0xFF123456],
'schemeVariant': 'tonalSpot',
'themeMode': 'dark',
'pureBlack': true,
'liquidGlass': true,
'systemFontFamily': 'Beta Serif',
};
test('starts theme colors over once and keeps the rest', () async {
final store = _FakeMigrationStore(
configMap: customized(),
version: Migration.currentVersion,
upgradeVersion: 0,
);
final config = await Migration(store: store).run();
final theme = config.themeProps;
expect(theme.primaryColor, defaultPrimaryColor);
expect(theme.primaryColors, defaultPrimaryColors);
expect(theme.colorStyle, ColorStyle.vivid);
expect(theme.themeMode, ThemeMode.dark);
expect(theme.pureBlack, isTrue);
expect(theme.liquidGlass, LiquidGlassMode.ultra);
expect(theme.fontFamily, 'Beta Serif');
expect(store.savedConfig, config);
expect(store.version, Migration.currentVersion);
expect(store.upgradeVersion, Migration.upgradeVersion);
expect(store.events, [
'getConfigMap',
'getVersion',
'saveConfig',
'setUpgradeVersion',
]);
final later = _FakeMigrationStore(
configMap: jsonDecode(
jsonEncode(config.copyWith.themeProps(primaryColor: 0xFF123456)),
),
version: store.version,
upgradeVersion: store.upgradeVersion,
);
final kept = await Migration(store: later).run();
expect(kept.themeProps.primaryColor, 0xFF123456);
expect(later.events, ['getConfigMap', 'getVersion']);
});
test('keeps standard glass on the ultra tier it became, once', () async {
Future<LiquidGlassMode> upgrade(String mode, int from) async {
final configMap = _createConfigMap();
(configMap['themeProps']! as Map<String, Object?>)['liquidGlass'] =
mode;
final store = _FakeMigrationStore(
configMap: configMap,
version: Migration.currentVersion,
upgradeVersion: from,
);
return (await Migration(store: store).run()).themeProps.liquidGlass;
}
expect(await upgrade('standard', 1), LiquidGlassMode.ultra);
expect(await upgrade('none', 1), LiquidGlassMode.none);
expect(await upgrade('standard', 2), LiquidGlassMode.standard);
});
test('a new install only records it', () async {
final store = _FakeMigrationStore(
configMap: null,
version: 0,
upgradeVersion: 0,
);
final config = await Migration(store: store).run();
expect(config.themeProps, defaultThemeProps);
expect(store.upgradeVersion, Migration.upgradeVersion);
expect(
store.events.where((event) => event == 'saveConfig'),
hasLength(1),
);
});
test('waits for the next launch when saving fails', () async {
final store = _FakeMigrationStore(
configMap: customized(),
version: Migration.currentVersion,
upgradeVersion: 0,
configSaveResult: false,
);
final config = await Migration(store: store).run();
expect(config.themeProps.primaryColor, 0xFF123456);
expect(store.upgradeVersion, 0);
});
});
test('keeps what it can read of a damaged current config', () async {
final configMap = _createConfigMap()
..['currentProfileId'] = 7
..['excludeSSIDs'] = 'home';
final store = _FakeMigrationStore(
configMap: configMap,
version: Migration.currentVersion,
);
final config = await Migration(store: store).run();
expect(config.currentProfileId, 7);
expect(config.excludeSSIDs, isEmpty);
expect(store.events, ['getConfigMap', 'getVersion']);
});
2026-08-05 10:57:11 +08:00
test('keeps the current version when password obfuscation fails', () async {
final configMap = _createConfigMap(
davProps: const DAVProps(uri: 'https://example.com/dav', user: 'user'),
);
final davProps = configMap['davProps']! as Map<String, Object?>;
davProps['password'] = 'secret';
final store = _FakeMigrationStore(
configMap: configMap,
version: Migration.currentVersion,
configSaveResult: false,
);
await expectLater(
Migration(store: store).run(),
throwsA(isA<StateError>()),
);
expect(store.events, ['getConfigMap', 'getVersion', 'saveConfig']);
expect(store.version, Migration.currentVersion);
});
});
}
Map<String, Object?> _createConfigMap({DAVProps? davProps}) {
return jsonDecode(
jsonEncode(Config(themeProps: defaultThemeProps, davProps: davProps)),
)
as Map<String, Object?>;
}
Map<String, Object?> _createClashConfigMap({required int mixedPort}) {
return jsonDecode(jsonEncode(PatchClashConfig(mixedPort: mixedPort)))
as Map<String, Object?>;
}
class _FakeMigrationStore implements MigrationStore {
final Map<String, Object?>? configMap;
final Map<String, Object?>? clashConfigMap;
final bool configSaveResult;
final bool available;
2026-08-05 10:57:11 +08:00
final List<String> events = [];
int version;
int upgradeVersion;
2026-08-05 10:57:11 +08:00
Config? savedConfig;
MigrationData? restoredData;
bool didClearClashConfig = false;
_FakeMigrationStore({
required this.configMap,
required this.version,
this.upgradeVersion = Migration.upgradeVersion,
2026-08-05 10:57:11 +08:00
this.clashConfigMap,
this.configSaveResult = true,
this.available = true,
2026-08-05 10:57:11 +08:00
});
@override
Future<bool> get isAvailable async {
events.add('isAvailable');
return available;
}
2026-08-05 10:57:11 +08:00
@override
Future<void> clearClashConfig() async {
events.add('clearClashConfig');
didClearClashConfig = true;
}
@override
Future<Map<String, Object?>?> getClashConfigMap() async {
events.add('getClashConfigMap');
return clashConfigMap;
}
@override
Future<Map<String, Object?>?> getConfigMap() async {
events.add('getConfigMap');
return configMap;
}
@override
Future<int> getVersion() async {
events.add('getVersion');
return version;
}
@override
Future<void> restore(MigrationData data) async {
events.add('restore');
restoredData = data;
}
@override
Future<bool> saveConfig(Config config) async {
events.add('saveConfig');
savedConfig = config;
return configSaveResult;
}
@override
Future<void> setVersion(int version) async {
events.add('setVersion');
this.version = version;
}
@override
Future<int> getUpgradeVersion() async => upgradeVersion;
@override
Future<void> setUpgradeVersion(int version) async {
events.add('setUpgradeVersion');
upgradeVersion = version;
}
2026-08-05 10:57:11 +08:00
}