import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:plezy/services/settings_export_service.dart'; import '../test_helpers/prefs.dart'; // NOTE on coverage scope: // `SettingsExportService.exportToFile` and `importFromFile` both call into // platform plumbing (FilePicker, PackageInfo, path_provider, dart:io.File). // Per the task brief we only round-trip through the *pure* helpers // `buildExportMap` and `applyImportMap` against an in-memory // SharedPreferencesWithCache. That covers the user-prefix re-scoping, the // allow/deny filtering, and the typed value (de)serialization — which is // where the format-stability risk lives. void main() { setUp(resetSharedPreferencesForTest); // ============================================================ // buildExportMap — header fields // ============================================================ group('buildExportMap header', () { test('emits the documented format version, an ISO8601 timestamp, and the platform', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final out = SettingsExportService.buildExportMap(prefs); expect(out['formatVersion'], SettingsExportService.formatVersion); expect(out['appVersion'], ''); expect(out['exportedAt'], isA()); // Sanity: the timestamp parses as an ISO-8601 instant. expect(() => DateTime.parse(out['exportedAt'] as String), returnsNormally); expect(out['platform'], isA()); expect(out['prefs'], isA()); }); test('honors the supplied appVersion', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final out = SettingsExportService.buildExportMap(prefs, appVersion: '1.2.3'); expect(out['appVersion'], '1.2.3'); }); }); // ============================================================ // buildExportMap — type encoding round-trip // ============================================================ group('buildExportMap type encoding', () { test('encodes bool / int / double / string / stringList with type markers', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setBool('flag_a', true); await prefs.setInt('count_a', 42); await prefs.setDouble('volume', 0.75); await prefs.setString('name', 'plezy'); await prefs.setStringList('list_a', const ['x', 'y']); final out = SettingsExportService.buildExportMap(prefs); final p = out['prefs'] as Map; expect(p['flag_a'], {'type': 'bool', 'value': true}); expect(p['count_a'], {'type': 'int', 'value': 42}); expect(p['volume'], {'type': 'double', 'value': 0.75}); expect(p['name'], {'type': 'string', 'value': 'plezy'}); expect(p['list_a'], { 'type': 'stringList', 'value': ['x', 'y'], }); }); }); // ============================================================ // buildExportMap — denylist filtering // ============================================================ group('buildExportMap denylist', () { test('drops exact-deny credential keys', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); // Sample of the credential bucket — should never leak. await prefs.setString('plex_token', 'abc'); await prefs.setString('client_identifier', 'xyz'); await prefs.setString('current_user_uuid', 'user-1'); await prefs.setString('active_app_profile_id', 'profile-1'); await prefs.setString('user_profile', '{}'); await prefs.setString('credential_vault_key_v1', 'base64-key'); // Plus a good-faith key that should stay. await prefs.setBool('keep_me', true); final out = SettingsExportService.buildExportMap(prefs); final p = out['prefs'] as Map; expect(p, isNot(contains('plex_token'))); expect(p, isNot(contains('client_identifier'))); expect(p, isNot(contains('current_user_uuid'))); expect(p, isNot(contains('active_app_profile_id'))); expect(p, isNot(contains('user_profile'))); expect(p, isNot(contains('credential_vault_key_v1'))); expect(p, contains('keep_me')); }); test('drops prefix-deny keys', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setString('server_endpoint_srv1', 'http://x'); await prefs.setInt('episode_count_show42', 24); await prefs.setInt('watched_threshold_srv1', 95); await prefs.setString('trakt_access_token', 'secret'); await prefs.setString('plex_home_users_conn-1', '[{"title":"Kid"}]'); await prefs.setInt('profile_last_used_profile-1', 123); // The trakt feature flag uses a different prefix and SHOULD survive. await prefs.setBool('enable_trakt_scrobble', true); final out = SettingsExportService.buildExportMap(prefs); final p = out['prefs'] as Map; expect(p, isNot(contains('server_endpoint_srv1'))); expect(p, isNot(contains('episode_count_show42'))); expect(p, isNot(contains('watched_threshold_srv1'))); expect(p, isNot(contains('trakt_access_token'))); expect(p, isNot(contains('plex_home_users_conn-1'))); expect(p, isNot(contains('profile_last_used_profile-1'))); expect(p, contains('enable_trakt_scrobble')); }); test('drops MAL / AniList / SIMKL session keys but keeps their feature toggles', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); // Stripped tracker session tokens — these would carry access_token / // refresh_token JSON if they leaked into the export. await prefs.setString('mal_session', '{"access_token":"a","refresh_token":"r"}'); await prefs.setString('anilist_session', '{"access_token":"a"}'); await prefs.setString('simkl_session', '{"access_token":"a"}'); // Feature toggles use the `enable_` prefix and SHOULD survive. await prefs.setBool('enable_mal_scrobble', true); await prefs.setBool('enable_anilist_scrobble', true); await prefs.setBool('enable_simkl_scrobble', true); final out = SettingsExportService.buildExportMap(prefs); final p = out['prefs'] as Map; expect(p, isNot(contains('mal_session'))); expect(p, isNot(contains('anilist_session'))); expect(p, isNot(contains('simkl_session'))); expect(p, contains('enable_mal_scrobble')); expect(p, contains('enable_anilist_scrobble')); expect(p, contains('enable_simkl_scrobble')); }); test('user-scoped tracker sessions are dropped after the user prefix is stripped', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); // TrackerAccountStore writes under user_{uuid}_{baseKey}. After the // active-user prefix is stripped on export, the key falls under the // tracker prefix denylist. await prefs.setString('user_alice_mal_session', '{"access_token":"a"}'); await prefs.setString('user_alice_anilist_session', '{"access_token":"a"}'); await prefs.setString('user_alice_simkl_session', '{"access_token":"a"}'); await prefs.setString('user_alice_trakt_session', '{"access_token":"a"}'); final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); final p = out['prefs'] as Map; expect(p, isNot(contains('mal_session'))); expect(p, isNot(contains('anilist_session'))); expect(p, isNot(contains('simkl_session'))); expect(p, isNot(contains('trakt_session'))); }); test('drops the internal migration flag', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setBool('buffer_size_migrated_to_auto', true); final out = SettingsExportService.buildExportMap(prefs); expect((out['prefs'] as Map), isNot(contains('buffer_size_migrated_to_auto'))); }); }); // ============================================================ // buildExportMap — user-prefix scoping // ============================================================ group('buildExportMap user-scoping', () { test('strips the active user prefix on export', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setStringList('user_alice_library_order', const ['a', 'b']); await prefs.setBool('user_alice_hidden_libraries_does_not_exist', true); final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); final p = out['prefs'] as Map; // Active user's keys land under their *base* names. expect(p, contains('library_order')); expect(p['library_order'], { 'type': 'stringList', 'value': ['a', 'b'], }); // Anything else under user_ that *isn't* the active user is excluded — // the synthetic key above lives under "alice" and so it goes through. expect(p, contains('hidden_libraries_does_not_exist')); }); test('skips other users\' scoped keys entirely', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setStringList('user_alice_library_order', const ['a']); await prefs.setStringList('user_bob_library_order', const ['b']); final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); final p = out['prefs'] as Map; // alice's value made it through (stripped to base key). expect(p['library_order'], { 'type': 'stringList', 'value': ['a'], }); // bob's was filtered out — there's no second pref with that name. expect(p.values.where((v) => (v as Map)['value'] is List && (v['value'] as List).contains('b')), isEmpty); }); test('without currentUserUuid: every user_-prefixed key is skipped', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setStringList('user_alice_library_order', const ['a']); await prefs.setBool('global_flag', true); final out = SettingsExportService.buildExportMap(prefs); // no UUID final p = out['prefs'] as Map; expect(p, contains('global_flag')); // Every user_-scoped key is skipped because we have no active user. expect(p.keys.where((k) => k.startsWith('user_')), isEmpty); expect(p, isNot(contains('library_order'))); }); }); // ============================================================ // applyImportMap — version + structure validation // ============================================================ group('applyImportMap validation', () { test('throws when formatVersion is missing or wrong type', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); // Missing expect( () => SettingsExportService.applyImportMap({'prefs': const {}}, prefs, currentUserUuid: 'u'), throwsA(isA()), ); // Wrong type expect( () => SettingsExportService.applyImportMap( {'formatVersion': 'one', 'prefs': const {}}, prefs, currentUserUuid: 'u', ), throwsA(isA()), ); }); test('throws when formatVersion is newer than the supported one', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); expect( () => SettingsExportService.applyImportMap( {'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const {}}, prefs, currentUserUuid: 'u', ), throwsA(isA()), ); }); test('throws when prefs is missing or not a map', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); expect( () => SettingsExportService.applyImportMap( {'formatVersion': SettingsExportService.formatVersion}, prefs, currentUserUuid: 'u', ), throwsA(isA()), ); expect( () => SettingsExportService.applyImportMap( {'formatVersion': SettingsExportService.formatVersion, 'prefs': 'not-a-map'}, prefs, currentUserUuid: 'u', ), throwsA(isA()), ); }); }); // ============================================================ // applyImportMap — typed writes // ============================================================ group('applyImportMap typed writes', () { test('writes bool / int / double / string / stringList back into prefs', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final result = await SettingsExportService.applyImportMap( { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { 'a_flag': {'type': 'bool', 'value': true}, 'a_int': {'type': 'int', 'value': 7}, 'a_double': {'type': 'double', 'value': 1.5}, 'a_string': {'type': 'string', 'value': 'hi'}, 'a_list': { 'type': 'stringList', 'value': ['x', 'y'], }, }, }, prefs, currentUserUuid: 'alice', ); expect(result.keysImported, 5); expect(result.keysSkipped, 0); expect(prefs.getBool('a_flag'), isTrue); expect(prefs.getInt('a_int'), 7); expect(prefs.getDouble('a_double'), 1.5); expect(prefs.getString('a_string'), 'hi'); expect(prefs.getStringList('a_list'), ['x', 'y']); }); test('double accepts num input (importing an int as double)', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final result = await SettingsExportService.applyImportMap( { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { // Value is encoded as int but typed as double — should still write. 'speed': {'type': 'double', 'value': 2}, }, }, prefs, currentUserUuid: 'alice', ); expect(result.keysImported, 1); expect(prefs.getDouble('speed'), 2.0); }); test('skips entries with mismatched type/value pairs without throwing', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final result = await SettingsExportService.applyImportMap( { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { // bool with non-bool value 'bad_bool': {'type': 'bool', 'value': 'yes'}, // unknown type tag 'bad_type': {'type': 'enum', 'value': 'foo'}, // not a map at all 'not_map': 'whatever', // missing type key 'no_type': {'value': 1}, // type isn't a string 'type_not_str': {'type': 1, 'value': 1}, }, }, prefs, currentUserUuid: 'alice', ); expect(result.keysImported, 0); expect(result.keysSkipped, 5); // None of the bad keys ended up in prefs. expect(prefs.getBool('bad_bool'), isNull); expect(prefs.getString('bad_type'), isNull); expect(prefs.getString('not_map'), isNull); }); test('skips deny-listed keys even if present in the import payload', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final result = await SettingsExportService.applyImportMap( { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { 'plex_token': {'type': 'string', 'value': 'malicious'}, 'credential_vault_key_v1': {'type': 'string', 'value': 'attacker-key'}, 'active_app_profile_id': {'type': 'string', 'value': 'stale-profile'}, 'server_endpoint_srv': {'type': 'string', 'value': 'http://attacker.test'}, 'plex_home_users_conn': {'type': 'string', 'value': '[]'}, 'profile_last_used_stale': {'type': 'int', 'value': 1}, 'good_key': {'type': 'bool', 'value': true}, }, }, prefs, currentUserUuid: 'alice', ); expect(result.keysImported, 1); expect(result.keysSkipped, 6); expect(prefs.getString('plex_token'), isNull); expect(prefs.getString('credential_vault_key_v1'), isNull); expect(prefs.getString('active_app_profile_id'), isNull); expect(prefs.getString('server_endpoint_srv'), isNull); expect(prefs.getString('plex_home_users_conn'), isNull); expect(prefs.getInt('profile_last_used_stale'), isNull); expect(prefs.getBool('good_key'), isTrue); }); }); // ============================================================ // applyImportMap — user-scoped re-scoping // ============================================================ group('applyImportMap user-scoping', () { test('re-applies the active user prefix to scoped base keys', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); final result = await SettingsExportService.applyImportMap( { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { // exact-match scoped base keys 'library_order': { 'type': 'stringList', 'value': ['a', 'b'], }, 'hidden_libraries': {'type': 'string', 'value': '["lib1"]'}, // prefix-match scoped base keys 'library_filters_section1': {'type': 'string', 'value': '{}'}, 'library_sort_section1': {'type': 'string', 'value': 'titleSort'}, 'library_grouping_section1': {'type': 'string', 'value': 'shows'}, 'library_tab_section1': {'type': 'string', 'value': 'recommended'}, // global key — must NOT be scoped 'enable_hardware_decoding': {'type': 'bool', 'value': true}, }, }, prefs, currentUserUuid: 'alice', ); expect(result.keysImported, 7); // Scoped keys land under user_alice_* expect(prefs.getStringList('user_alice_library_order'), ['a', 'b']); expect(prefs.getString('user_alice_hidden_libraries'), '["lib1"]'); expect(prefs.getString('user_alice_library_filters_section1'), '{}'); expect(prefs.getString('user_alice_library_sort_section1'), 'titleSort'); expect(prefs.getString('user_alice_library_grouping_section1'), 'shows'); expect(prefs.getString('user_alice_library_tab_section1'), 'recommended'); // Global key stays unscoped. expect(prefs.getBool('enable_hardware_decoding'), isTrue); expect(prefs.getBool('user_alice_enable_hardware_decoding'), isNull); }); }); // ============================================================ // Round-trip // ============================================================ group('round-trip', () { test('build → JSON → parse → apply produces the same key/value/type', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); // Seed a representative mix. await prefs.setBool('enable_hardware_decoding', true); await prefs.setInt('seek_time_small', 15); await prefs.setDouble('volume', 0.75); await prefs.setString('preferred_video_codec', 'h264'); await prefs.setStringList('shader_list', const ['a', 'b', 'c']); // User-scoped data for "alice". await prefs.setStringList('user_alice_library_order', const ['lib-1', 'lib-2']); // Credential we expect to be stripped. await prefs.setString('plex_token', 'never-this'); // Export. final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '9.9.9'); final encoded = json.encode(exportMap); // Wipe prefs to simulate a fresh device. await prefs.clear(); // Confirm wipe. expect(prefs.getInt('seek_time_small'), isNull); expect(prefs.getStringList('user_alice_library_order'), isNull); // Parse back and import — same alice, so scoped keys round-trip cleanly. final decoded = json.decode(encoded) as Map; final result = await SettingsExportService.applyImportMap(decoded, prefs, currentUserUuid: 'alice'); // 6 expected keys round-trip; the count includes the unrelated // `plezy_legacy_prefs_migrated_v1` flag the cache plants. We only assert // it is at LEAST our expected six keys, not an exact count. expect(result.keysImported, greaterThanOrEqualTo(6)); expect(result.keysSkipped, 0); // Values restored under their original keys (with re-applied scoping). expect(prefs.getBool('enable_hardware_decoding'), isTrue); expect(prefs.getInt('seek_time_small'), 15); expect(prefs.getDouble('volume'), 0.75); expect(prefs.getString('preferred_video_codec'), 'h264'); expect(prefs.getStringList('shader_list'), ['a', 'b', 'c']); expect(prefs.getStringList('user_alice_library_order'), ['lib-1', 'lib-2']); // Credential never came back. expect(prefs.getString('plex_token'), isNull); }); test('cross-user round-trip: alice exports → bob imports → keys land under bob', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setStringList('user_alice_library_order', const ['lib-a', 'lib-b']); final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); // Wipe alice's data. await prefs.clear(); // Bob imports. Scoped base key gets re-applied with bob's prefix. final result = await SettingsExportService.applyImportMap(exportMap, prefs, currentUserUuid: 'bob'); // The cache plants `plezy_legacy_prefs_migrated_v1` on first init, so // the export count includes that flag too. Just confirm the scoped // value made it through. expect(result.keysImported, greaterThanOrEqualTo(1)); // Alice's data is now under bob's namespace. expect(prefs.getStringList('user_bob_library_order'), ['lib-a', 'lib-b']); expect(prefs.getStringList('user_alice_library_order'), isNull); }); }); // ============================================================ // Exception types // ============================================================ group('exception types', () { test('NoUserSignedInException is a SettingsExportException', () { const ex = NoUserSignedInException(); expect(ex, isA()); expect(ex.toString(), contains('No user is signed in')); }); test('InvalidExportFileException is a SettingsExportException with message', () { const ex = InvalidExportFileException('bad shape'); expect(ex, isA()); expect(ex.toString(), contains('bad shape')); }); }); }