import 'dart:convert'; import 'package:fl_clash/common/migration.dart'; import 'package:fl_clash/common/constant.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/ui/color.dart'; import 'package:material_ui/material_ui.dart' show ThemeMode; 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; 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; final savedDavProps = savedConfigMap['davProps']! as Map; 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 = { 'proxiesStyle': {}, 'dav': { 'uri': 'https://example.com/dav', 'user': 'user', 'password': 'secret', }, }; final store = _FakeMigrationStore( configMap: configMap, version: 0, clashConfigMap: {'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()), ); 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', ]); }); group('upgrade', () { Map 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 upgrade(String mode, int from) async { final configMap = _createConfigMap(); (configMap['themeProps']! as Map)['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']); }); 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; davProps['password'] = 'secret'; final store = _FakeMigrationStore( configMap: configMap, version: Migration.currentVersion, configSaveResult: false, ); await expectLater( Migration(store: store).run(), throwsA(isA()), ); expect(store.events, ['getConfigMap', 'getVersion', 'saveConfig']); expect(store.version, Migration.currentVersion); }); }); } Map _createConfigMap({DAVProps? davProps}) { return jsonDecode( jsonEncode(Config(themeProps: defaultThemeProps, davProps: davProps)), ) as Map; } Map _createClashConfigMap({required int mixedPort}) { return jsonDecode(jsonEncode(PatchClashConfig(mixedPort: mixedPort))) as Map; } class _FakeMigrationStore implements MigrationStore { final Map? configMap; final Map? clashConfigMap; final bool configSaveResult; final bool available; final List events = []; int version; int upgradeVersion; Config? savedConfig; MigrationData? restoredData; bool didClearClashConfig = false; _FakeMigrationStore({ required this.configMap, required this.version, this.upgradeVersion = Migration.upgradeVersion, this.clashConfigMap, this.configSaveResult = true, this.available = true, }); @override Future get isAvailable async { events.add('isAvailable'); return available; } @override Future clearClashConfig() async { events.add('clearClashConfig'); didClearClashConfig = true; } @override Future?> getClashConfigMap() async { events.add('getClashConfigMap'); return clashConfigMap; } @override Future?> getConfigMap() async { events.add('getConfigMap'); return configMap; } @override Future getVersion() async { events.add('getVersion'); return version; } @override Future restore(MigrationData data) async { events.add('restore'); restoredData = data; } @override Future saveConfig(Config config) async { events.add('saveConfig'); savedConfig = config; return configSaveResult; } @override Future setVersion(int version) async { events.add('setVersion'); this.version = version; } @override Future getUpgradeVersion() async => upgradeVersion; @override Future setUpgradeVersion(int version) async { events.add('setUpgradeVersion'); upgradeVersion = version; } }