Files
plezy/test/services/settings_export_service_test.dart
T

691 lines
29 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/file_picker_service.dart';
import 'package:plezy/services/settings_export_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/trackers/tracker_constants.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _FakeFilePicker picker;
setUp(() {
resetSharedPreferencesForTest();
SettingsExportService.debugBeforeImportWrite = null;
picker = _FakeFilePicker();
FilePickerService.setDelegateForTesting(picker);
PackageInfo.setMockInitialValues(
appName: 'Plezy',
packageName: 'com.example.plezy',
version: '1.2.3',
buildNumber: '4',
buildSignature: '',
);
});
tearDown(() {
SettingsExportService.debugBeforeImportWrite = null;
FilePickerService.setDelegateForTesting(null);
});
group('portable settings registry', () {
test('exports scalar and JSON-backed values and strips only the active-user library scope', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setInt('seek_time_small', 42);
await prefs.setDouble('volume', 75.5);
await prefs.setString('preferred_video_codec', 'h264');
await prefs.setString('user_alice_hidden_libraries', jsonEncode(['server-a:hidden']));
await prefs.setString('user_alice_library_order', jsonEncode(['movies', 'shows']));
await prefs.setString('user_bob_hidden_libraries', jsonEncode(['private-hidden']));
await prefs.setString('user_bob_library_order', jsonEncode(['private-order']));
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '1.2.3');
final exported = out['prefs'] as Map<String, dynamic>;
expect(out['formatVersion'], SettingsExportService.formatVersion);
expect(out['appVersion'], '1.2.3');
expect(DateTime.tryParse(out['exportedAt'] as String), isNotNull);
expect(exported['enable_hardware_decoding'], {'type': 'bool', 'value': true});
expect(exported['seek_time_small'], {'type': 'int', 'value': 42});
expect(exported['volume'], {'type': 'double', 'value': 75.5});
expect(exported['preferred_video_codec'], {'type': 'string', 'value': 'h264'});
expect(exported['hidden_libraries'], {
'type': 'string',
'value': jsonEncode(['server-a:hidden']),
});
expect(exported['library_order'], {
'type': 'string',
'value': jsonEncode(['movies', 'shows']),
});
expect(jsonEncode(out), isNot(contains('private-hidden')));
expect(jsonEncode(out), isNot(contains('private-order')));
});
test('excludes device-local download roots while preserving portable download controls', () async {
const sourcePath = '/source-device/downloads';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('custom_download_path', sourcePath);
await prefs.setString('custom_download_path_type', 'saf');
await prefs.setBool('download_on_wifi_only', false);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final exported = out['prefs'] as Map<String, dynamic>;
final encoded = jsonEncode(out);
expect(out['formatVersion'], 1);
expect(exported['download_on_wifi_only'], {'type': 'bool', 'value': false});
expect(exported, isNot(contains('custom_download_path')));
expect(exported, isNot(contains('custom_download_path_type')));
expect(encoded, isNot(contains(sourcePath)));
});
test('round-trips a custom shader selection as a portable disabled selection', () async {
const custom = ShaderPreset(
id: 'custom_local-only.glsl',
name: 'Local only',
type: ShaderPresetType.custom,
fileName: 'local-only.glsl',
);
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('custom_shader_presets', jsonEncode([custom.toJson()]));
await prefs.setString('global_shader_preset', custom.id);
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final exported = export['prefs'] as Map<String, dynamic>;
expect(exported['global_shader_preset'], {'type': 'string', 'value': ShaderPreset.none.id});
expect(exported, isNot(contains('custom_shader_presets')));
expect(jsonEncode(export), isNot(contains(custom.fileName!)));
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'bob');
expect(result.keysImported, 1);
expect(result.keysSkipped, 0);
expect(prefs.getString('global_shader_preset'), ShaderPreset.none.id);
expect(prefs.getString('custom_shader_presets'), isNull);
});
test('fails closed for unknown, credential, account, path, history, and runtime keys', () async {
const canaries = ['SEERR-BEARER-CANARY', 'ACCOUNT-ID-CANARY', 'DEVICE-PATH-CANARY', 'RUNTIME-TIME-CANARY'];
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_trakt_scrobble', true);
await prefs.setString('user_alice_seerr_session', '{"cookie":"${canaries[0]}","account":"${canaries[1]}"}');
await prefs.setString('current_user_uuid', canaries[1]);
await prefs.setString('custom_download_path', canaries[2]);
await prefs.setString('custom_download_path_type', 'saf');
await prefs.setBool('crash_reporting', true);
await prefs.setString('custom_relay_url', 'https://${canaries[2]}.invalid');
await prefs.setString('update_last_check_time', canaries[3]);
await prefs.setString('watch_together_recent_rooms', canaries[1]);
await prefs.setString('user_alice_watch_together_recent_rooms', canaries[1]);
await prefs.setString('future_runtime_key', 'unknown');
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final encoded = jsonEncode(out);
final exported = out['prefs'] as Map<String, dynamic>;
expect(exported.keys, contains('enable_trakt_scrobble'));
expect(exported.keys, isNot(contains('seerr_session')));
expect(exported.keys, isNot(contains('current_user_uuid')));
expect(exported.keys, isNot(contains('custom_download_path')));
expect(exported.keys, isNot(contains('custom_download_path_type')));
expect(exported.keys, isNot(contains('crash_reporting')));
expect(exported.keys, isNot(contains('custom_relay_url')));
expect(exported.keys, isNot(contains('update_last_check_time')));
expect(exported.keys, isNot(contains('watch_together_recent_rooms')));
expect(exported.keys, isNot(contains('user_alice_watch_together_recent_rooms')));
expect(exported.keys, isNot(contains('future_runtime_key')));
for (final canary in canaries) {
expect(encoded, isNot(contains(canary)));
}
});
test('does not export any user-scoped value without an active user', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('user_alice_library_order', jsonEncode(['movies']));
await prefs.setBool('enable_hdr', true);
final exported = SettingsExportService.buildExportMap(prefs)['prefs'] as Map<String, dynamic>;
expect(exported, contains('enable_hdr'));
expect(exported, isNot(contains('library_order')));
});
test('never exports tvOS database recovery generations or payloads', () async {
const canary = 'PROTECTED-RECOVERY-PAYLOAD-CANARY';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('tvos_db_recovery_manifest_v1', '{"state":"committed"}');
await prefs.setString('tvos_db_recovery_identity_v1', canary);
await prefs.setString('tvos_db_recovery_pending_v1', canary);
await prefs.setBool('enable_hdr', true);
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final encoded = jsonEncode(export);
final exported = export['prefs'] as Map<String, dynamic>;
expect(exported, contains('enable_hdr'));
expect(exported.keys.where((key) => key.startsWith('tvos_db_recovery_')), isEmpty);
expect(encoded, isNot(contains(canary)));
});
});
group('transactional import', () {
test('validates version and structure before writing', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await expectLater(
SettingsExportService.applyImportMap({'prefs': const {}}, prefs, currentUserUuid: 'alice'),
throwsA(isA<InvalidExportFileException>()),
);
await expectLater(
SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const {}},
prefs,
currentUserUuid: 'alice',
),
throwsA(isA<InvalidExportFileException>()),
);
await expectLater(
SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion, 'prefs': 'invalid'},
prefs,
currentUserUuid: 'alice',
),
throwsA(isA<InvalidExportFileException>()),
);
expect(prefs.getBool('enable_hdr'), isNull);
});
test('round-trips both filter modes and IDs for every tracker service', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
for (final mode in TrackerLibraryFilterMode.values) {
await prefs.clear();
for (final service in TrackerService.values) {
await prefs.setString(SettingsService.trackerFilterModePref(service).key, mode.name);
await prefs.setStringList(SettingsService.trackerFilterIdsPref(service).key, [
'${service.name}:library-a',
'${service.name}:library-b',
]);
}
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'source-user');
final exported = export['prefs'] as Map<String, dynamic>;
for (final service in TrackerService.values) {
final modeKey = SettingsService.trackerFilterModePref(service).key;
final idsKey = SettingsService.trackerFilterIdsPref(service).key;
expect(exported[modeKey], {'type': 'string', 'value': mode.name});
expect(exported[idsKey], {
'type': 'stringList',
'value': ['${service.name}:library-a', '${service.name}:library-b'],
});
}
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'target-user');
expect(result.keysImported, TrackerService.values.length * 2);
expect(result.keysSkipped, 0);
for (final service in TrackerService.values) {
expect(prefs.getString(SettingsService.trackerFilterModePref(service).key), mode.name);
expect(prefs.getStringList(SettingsService.trackerFilterIdsPref(service).key), [
'${service.name}:library-a',
'${service.name}:library-b',
]);
}
}
});
test('preserves an empty whitelist so import cannot broaden tracker access', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
for (final service in TrackerService.values) {
await prefs.setString(
SettingsService.trackerFilterModePref(service).key,
TrackerLibraryFilterMode.whitelist.name,
);
await prefs.setStringList(SettingsService.trackerFilterIdsPref(service).key, const []);
}
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'source-user');
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'target-user');
final settings = await SettingsService.getInstance();
expect(result.keysImported, TrackerService.values.length * 2);
expect(result.keysSkipped, 0);
for (final service in TrackerService.values) {
expect(settings.read(SettingsService.trackerFilterModePref(service)), TrackerLibraryFilterMode.whitelist);
expect(settings.read(SettingsService.trackerFilterIdsPref(service)), isEmpty);
expect(settings.isLibraryAllowedForTracker(service, '${service.name}:unlisted'), isFalse);
expect(settings.isLibraryAllowedForTracker(service, null), isFalse);
}
});
test('rejects tracker preference keys with unknown service suffixes', () async {
const modeKey = 'tracker_library_filter_mode_future';
const idsKey = 'tracker_library_filter_ids_future';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(modeKey, 'local-mode');
await prefs.setStringList(idsKey, const ['local-id']);
final exported = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice')['prefs'] as Map;
expect(exported, isNot(contains(modeKey)));
expect(exported, isNot(contains(idsKey)));
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
modeKey: {'type': 'string', 'value': TrackerLibraryFilterMode.whitelist.name},
idsKey: {
'type': 'stringList',
'value': ['crafted-id'],
},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 2);
expect(prefs.getString(modeKey), 'local-mode');
expect(prefs.getStringList(idsKey), ['local-id']);
});
test('normalizes format-v1 hidden and order string lists into JSON string storage', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': 1,
'prefs': {
'hidden_libraries': {
'type': 'stringList',
'value': ['server-a:hidden'],
},
'library_order': {
'type': 'stringList',
'value': ['server-b:movies', 'server-a:shows'],
},
},
},
prefs,
currentUserUuid: 'target-user',
);
expect(result.keysImported, 2);
expect(result.keysSkipped, 0);
expect(prefs.getString('user_target-user_hidden_libraries'), jsonEncode(['server-a:hidden']));
expect(prefs.getString('user_target-user_library_order'), jsonEncode(['server-b:movies', 'server-a:shows']));
});
test('imports allowlisted values, re-scopes library settings, and skips unsafe entries', () async {
const seerrCanary = 'SEERR-IMPORT-CANARY';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('update_last_check_time', 'local-valid-value');
await prefs.setString('custom_download_path', '/target/device/downloads');
await prefs.setString('custom_download_path_type', 'file');
await prefs.setBool('crash_reporting', false);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hardware_decoding': {'type': 'bool', 'value': true},
'default_playback_speed': {'type': 'double', 'value': 1},
'library_order': {
'type': 'string',
'value': jsonEncode(['movies']),
},
'library_sort_movies': {'type': 'string', 'value': '{"key":"titleSort"}'},
'seerr_session': {'type': 'string', 'value': seerrCanary},
'update_last_check_time': {'type': 'string', 'value': 'crafted-invalid'},
'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'},
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
'crash_reporting': {'type': 'bool', 'value': true},
'custom_relay_url': {'type': 'string', 'value': 'https://relay.example.test'},
'watch_together_recent_rooms': {'type': 'string', 'value': '[]'},
'unknown_future_key': {'type': 'bool', 'value': true},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 4);
expect(result.keysSkipped, 8);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getDouble('default_playback_speed'), 1.0);
expect(prefs.getString('user_alice_library_order'), jsonEncode(['movies']));
expect(prefs.getString('user_alice_library_sort_movies'), '{"key":"titleSort"}');
expect(prefs.getString('seerr_session'), isNull);
expect(prefs.getString('user_alice_seerr_session'), isNull);
expect(prefs.getString('update_last_check_time'), 'local-valid-value');
expect(prefs.getString('custom_download_path'), '/target/device/downloads');
expect(prefs.getString('custom_download_path_type'), 'file');
expect(prefs.getBool('crash_reporting'), isFalse);
expect(prefs.getString('custom_relay_url'), isNull);
expect(prefs.getString('user_alice_watch_together_recent_rooms'), isNull);
expect(prefs.getBool('unknown_future_key'), isNull);
});
test('skips source download roots and preserves the target device root', () async {
const targetPath = '/target-device/downloads';
const sourcePath = '/source-device/downloads';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('custom_download_path', targetPath);
await prefs.setString('custom_download_path_type', 'file');
await prefs.setBool('download_on_wifi_only', true);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': 1,
'prefs': {
'custom_download_path': {'type': 'string', 'value': sourcePath},
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
'download_on_wifi_only': {'type': 'bool', 'value': false},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(result.keysSkipped, 2);
expect(prefs.getBool('download_on_wifi_only'), isFalse);
expect(prefs.getString('custom_download_path'), targetPath);
expect(prefs.getString('custom_download_path_type'), 'file');
expect(prefs.getString('custom_download_path'), isNot(contains(sourcePath)));
});
test('reports an unresolved custom shader selection as skipped', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('global_shader_preset', ShaderPreset.nvscalerDefault.id);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'global_shader_preset': {'type': 'string', 'value': 'custom_missing.glsl'},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 1);
expect(prefs.getString('global_shader_preset'), ShaderPreset.nvscalerDefault.id);
});
test('skips malformed or mismatched entries before applying valid mutations', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hdr': {'type': 'bool', 'value': 'yes'},
'seek_time_small': {'type': 'string', 'value': '10'},
'volume': {'value': 50},
'preferred_video_codec': 'not-an-entry',
'enable_hardware_decoding': {'type': 'bool', 'value': true},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(result.keysSkipped, 4);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getBool('enable_hdr'), isNull);
expect(prefs.getInt('seek_time_small'), isNull);
});
test('rolls every mutation back when a later preference write fails', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_hdr', false);
var writes = 0;
SettingsExportService.debugBeforeImportWrite = (_) {
writes++;
if (writes == 2) throw StateError('synthetic write failure');
};
await expectLater(
SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hdr': {'type': 'bool', 'value': true},
'seek_time_small': {'type': 'int', 'value': 15},
},
},
prefs,
currentUserUuid: 'alice',
),
throwsA(isA<StateError>()),
);
expect(prefs.getBool('enable_hdr'), isFalse);
expect(prefs.getInt('seek_time_small'), isNull);
});
test('round-trips portable values across user scopes without account identifiers', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setString('user_alice_hidden_libraries', jsonEncode(['server-a:hidden']));
await prefs.setString('user_alice_library_order', jsonEncode(['server-b:movies']));
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
expect(jsonEncode(export), isNot(contains('alice')));
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'bob');
expect(result.keysImported, 3);
expect(result.keysSkipped, 0);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getString('user_bob_hidden_libraries'), jsonEncode(['server-a:hidden']));
expect(prefs.getString('user_bob_library_order'), jsonEncode(['server-b:movies']));
expect(prefs.getString('user_alice_hidden_libraries'), isNull);
expect(prefs.getString('user_alice_library_order'), isNull);
});
test('malicious import cannot replace any tvOS database recovery key', () async {
const originalManifest = 'LOCAL-MANIFEST';
const originalIdentity = 'LOCAL-IDENTITY';
const originalPending = 'LOCAL-PENDING';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('tvos_db_recovery_manifest_v1', originalManifest);
await prefs.setString('tvos_db_recovery_identity_v1', originalIdentity);
await prefs.setString('tvos_db_recovery_pending_v1', originalPending);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'tvos_db_recovery_manifest_v1': {'type': 'string', 'value': 'MALICIOUS-MANIFEST'},
'tvos_db_recovery_identity_v1': {'type': 'string', 'value': 'MALICIOUS-IDENTITY'},
'tvos_db_recovery_pending_v1': {'type': 'string', 'value': 'MALICIOUS-PENDING'},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 3);
expect(prefs.getString('tvos_db_recovery_manifest_v1'), originalManifest);
expect(prefs.getString('tvos_db_recovery_identity_v1'), originalIdentity);
expect(prefs.getString('tvos_db_recovery_pending_v1'), originalPending);
});
});
group('file orchestration', () {
Future<void> seedActiveProfile() async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('active_app_profile_id', 'profile-a');
await prefs.setBool('enable_hdr', true);
}
Uint8List importBytes({bool enableHdr = false}) {
return Uint8List.fromList(
utf8.encode(
jsonEncode({
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'enable_hdr': {'type': 'bool', 'value': enableHdr},
},
}),
),
);
}
test('exports captured JSON bytes with package version and requested file contract', () async {
await seedActiveProfile();
picker.saveResult = '/tmp/plezy-settings.json';
final path = await SettingsExportService.exportToFile();
expect(path, '/tmp/plezy-settings.json');
expect(picker.lastSaveName, matches(RegExp(r'^plezy-settings-\d{8}\.json$')));
expect(picker.lastSaveExtensions, ['json']);
final decoded = jsonDecode(utf8.decode(picker.lastSaveBytes!)) as Map<String, dynamic>;
expect(decoded['appVersion'], '1.2.3');
expect((decoded['prefs'] as Map)['enable_hdr'], {'type': 'bool', 'value': true});
});
test('save cancellation and failure release the picker guard for a later operation', () async {
await seedActiveProfile();
picker.saveResult = null;
expect(await SettingsExportService.exportToFile(), isNull);
picker.saveError = PlatformException(code: 'save_failed');
await expectLater(SettingsExportService.exportToFile(), throwsA(isA<PlatformException>()));
picker.saveError = null;
picker.saveResult = '/tmp/recovered.json';
expect(await SettingsExportService.exportToFile(), '/tmp/recovered.json');
expect(picker.saveCalls, 3);
});
test('imports in-memory bytes and path-backed files', () async {
await seedActiveProfile();
final prefs = await BaseSharedPreferencesService.sharedCache();
picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]);
final memoryResult = await SettingsExportService.importFromFile();
expect(memoryResult?.keysImported, 1);
expect(prefs.getBool('enable_hdr'), isFalse);
final directory = await Directory.systemTemp.createTemp('plezy-settings-import-');
addTearDown(() => directory.delete(recursive: true));
final file = File('${directory.path}/settings.json');
await file.writeAsBytes(importBytes(enableHdr: true));
picker.pickResult = FilePickerResult([
PlatformFile(name: 'settings.json', size: await file.length(), path: file.path),
]);
final pathResult = await SettingsExportService.importFromFile();
expect(pathResult?.keysImported, 1);
expect(prefs.getBool('enable_hdr'), isTrue);
});
test('picker cancellation, malformed input, and unreadable path release the guard', () async {
await seedActiveProfile();
picker.pickResult = null;
expect(await SettingsExportService.importFromFile(), isNull);
picker.pickResult = FilePickerResult([
PlatformFile(name: 'bad.json', size: 1, bytes: Uint8List.fromList(utf8.encode('{bad'))),
]);
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<InvalidExportFileException>()));
picker.pickResult = FilePickerResult([
PlatformFile(name: 'missing.json', size: 1, path: '/path/that/does/not/exist.json'),
]);
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<FileSystemException>()));
picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]);
expect((await SettingsExportService.importFromFile())?.keysImported, 1);
expect(picker.pickCalls, 4);
picker.pickError = PlatformException(code: 'pick_failed');
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<PlatformException>()));
});
test('missing active profile rejects before opening the picker', () async {
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<NoUserSignedInException>()));
expect(picker.pickCalls, 0);
});
});
}
class _FakeFilePicker implements FilePickerDelegate {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
FilePickerResult? pickResult;
String? saveResult;
Object? pickError;
Object? saveError;
int pickCalls = 0;
int saveCalls = 0;
String? lastSaveName;
List<String>? lastSaveExtensions;
Uint8List? lastSaveBytes;
@override
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Function(FilePickerStatus)? onFileLoading,
bool allowCompression = false,
int compressionQuality = 0,
bool allowMultiple = false,
bool withData = false,
bool withReadStream = false,
bool lockParentWindow = false,
bool readSequential = false,
}) async {
pickCalls++;
final error = pickError;
if (error != null) throw error;
return pickResult;
}
@override
Future<String?> saveFile({
String? dialogTitle,
String? fileName,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Uint8List? bytes,
bool lockParentWindow = false,
}) async {
saveCalls++;
lastSaveName = fileName;
lastSaveExtensions = allowedExtensions;
lastSaveBytes = bytes;
final error = saveError;
if (error != null) throw error;
return saveResult;
}
}