diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index fc97e905..46254cfb 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; @@ -27,6 +28,13 @@ class DownloadStorageService { static DownloadStorageService get instance => _instance ??= DownloadStorageService._(); DownloadStorageService._(); + /// Drop the cached singleton so the next [instance] call returns a fresh + /// service. Test-only. + @visibleForTesting + static void resetForTesting() { + _instance = null; + } + Directory? _baseDownloadsDir; String? _artworkDirectoryPath; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 24a44153..caf079cb 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; import '../models/hotkey_model.dart'; @@ -425,6 +426,14 @@ class SettingsService extends BaseSharedPreferencesService { /// Synchronous access to the singleton, or null if not yet initialized. static SettingsService? get instanceOrNull => _cachedInstance; + /// Drop the cached singleton so the next [getInstance] call rebuilds against + /// the current SharedPreferences state. Test-only — pair with + /// [BaseSharedPreferencesService.resetForTesting]. + @visibleForTesting + static void resetForTesting() { + _cachedInstance = null; + } + static Map defaultKeyboardShortcuts() => _defaultKeyboardShortcuts(); static Map defaultKeyboardHotkeys() => _defaultKeyboardHotkeys(); diff --git a/pubspec.lock b/pubspec.lock index 0ce9fa69..61991f92 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -821,7 +821,7 @@ packages: source: hosted version: "2.2.1" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" @@ -853,7 +853,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" diff --git a/pubspec.yaml b/pubspec.yaml index eff9cd59..049f8173 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,6 +80,8 @@ dev_dependencies: dart_code_linter: ^3.2.1 drift_dev: ^2.28.3 shared_preferences_platform_interface: ^2.4.0 + path_provider_platform_interface: ^2.1.0 + plugin_platform_interface: ^2.1.0 dependency_overrides: auto_updater_platform_interface: diff --git a/test/mixins/disposable_change_notifier_mixin_test.dart b/test/mixins/disposable_change_notifier_mixin_test.dart new file mode 100644 index 00000000..d3b5e021 --- /dev/null +++ b/test/mixins/disposable_change_notifier_mixin_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mixins/disposable_change_notifier_mixin.dart'; + +class _Probe extends ChangeNotifier with DisposableChangeNotifierMixin {} + +void main() { + group('DisposableChangeNotifierMixin', () { + test('isDisposed is false on a fresh notifier', () { + final n = _Probe(); + expect(n.isDisposed, isFalse); + n.dispose(); + }); + + test('safeNotifyListeners returns true and notifies when not disposed', () { + final n = _Probe(); + var fired = 0; + n.addListener(() => fired++); + + final result = n.safeNotifyListeners(); + + expect(result, isTrue); + expect(fired, 1); + n.dispose(); + }); + + test('safeNotifyListeners returns false and does not notify after dispose', () { + final n = _Probe(); + var fired = 0; + n.addListener(() => fired++); + + n.dispose(); + final result = n.safeNotifyListeners(); + + expect(result, isFalse); + expect(fired, 0); + }); + + test('isDisposed flips to true after dispose()', () { + final n = _Probe(); + expect(n.isDisposed, isFalse); + + n.dispose(); + + expect(n.isDisposed, isTrue); + }); + + test('multiple safeNotifyListeners calls succeed before dispose', () { + final n = _Probe(); + var fired = 0; + n.addListener(() => fired++); + + expect(n.safeNotifyListeners(), isTrue); + expect(n.safeNotifyListeners(), isTrue); + expect(n.safeNotifyListeners(), isTrue); + + expect(fired, 3); + n.dispose(); + }); + + test('safeNotifyListeners after dispose does not throw', () { + final n = _Probe(); + n.dispose(); + + // Without the mixin's guard, ChangeNotifier.notifyListeners would throw + // a debug-only assertion. The whole point of the mixin is to no-op. + expect(n.safeNotifyListeners, returnsNormally); + }); + }); +} diff --git a/test/mixins/event_aware_test.dart b/test/mixins/event_aware_test.dart new file mode 100644 index 00000000..6854729b --- /dev/null +++ b/test/mixins/event_aware_test.dart @@ -0,0 +1,292 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mixins/event_aware.dart'; +import 'package:plezy/utils/base_notifier.dart'; +import 'package:plezy/utils/global_key_utils.dart'; +import 'package:plezy/utils/hierarchical_event_mixin.dart'; + +class _FakeEvent with HierarchicalEventMixin { + _FakeEvent({required this.serverId, required this.ratingKey, this.parentChain = const []}); + + @override + final String serverId; + + @override + final String ratingKey; + + @override + final List parentChain; + + @override + String get globalKey => buildGlobalKey(serverId, ratingKey); +} + +class _FakeNotifier extends BaseNotifier<_FakeEvent> {} + +/// Helper to drain the broadcast microtasks before reading received events. +Future _settle() => Future.delayed(Duration.zero); + +void main() { + group('subscribeToHierarchicalEvents', () { + late _FakeNotifier notifier; + late List<_FakeEvent> received; + + setUp(() { + notifier = _FakeNotifier(); + received = <_FakeEvent>[]; + }); + + tearDown(() => notifier.dispose()); + + test('delivers events when no filters are set (mounted, no serverId/keys)', () async { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => null, + onEvent: received.add, + ); + + final ev = _FakeEvent(serverId: 's1', ratingKey: '42'); + notifier.notify(ev); + await _settle(); + + expect(received, [ev]); + await sub.cancel(); + }); + + test('drops events when not mounted', () async { + var mounted = false; + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => mounted, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => null, + onEvent: received.add, + ); + + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '42')); + await _settle(); + expect(received, isEmpty); + + // Once mounted, future events flow. + mounted = true; + final ev = _FakeEvent(serverId: 's1', ratingKey: '99'); + notifier.notify(ev); + await _settle(); + expect(received, [ev]); + + await sub.cancel(); + }); + + test('filters by serverId when provided', () async { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => 's1', + globalKeys: () => null, + ratingKeys: () => null, + onEvent: received.add, + ); + + final keep = _FakeEvent(serverId: 's1', ratingKey: '1'); + final drop = _FakeEvent(serverId: 's2', ratingKey: '1'); + notifier.notify(drop); + notifier.notify(keep); + await _settle(); + + expect(received, [keep]); + await sub.cancel(); + }); + + test('globalKeys filter delivers events matching any global key', () async { + final keys = {buildGlobalKey('s1', '42'), buildGlobalKey('s1', '7')}; + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => keys, + ratingKeys: () => null, + onEvent: received.add, + ); + + final hit = _FakeEvent(serverId: 's1', ratingKey: '42'); + final miss = _FakeEvent(serverId: 's1', ratingKey: '9999'); + notifier.notify(hit); + notifier.notify(miss); + await _settle(); + + expect(received, [hit]); + await sub.cancel(); + }); + + test('globalKeys filter takes precedence over ratingKeys', () async { + // Even though ratingKeys would match '5', globalKeys path returns early + // and short-circuits the ratingKeys check. + final globalKeys = {buildGlobalKey('s1', '99')}; + final ratingKeys = {'5'}; + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => globalKeys, + ratingKeys: () => ratingKeys, + onEvent: received.add, + ); + + // ratingKey 5 matches the ratingKeys set but not the globalKeys set. + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '5')); + await _settle(); + expect(received, isEmpty); + + // Now an event matching the globalKeys set comes through. + final hit = _FakeEvent(serverId: 's1', ratingKey: '99'); + notifier.notify(hit); + await _settle(); + expect(received, [hit]); + + await sub.cancel(); + }); + + test('null ratingKeys delivers all events (when no other filters)', () async { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => null, + onEvent: received.add, + ); + + final a = _FakeEvent(serverId: 's1', ratingKey: '1'); + final b = _FakeEvent(serverId: 's2', ratingKey: '2'); + notifier.notify(a); + notifier.notify(b); + await _settle(); + + expect(received, [a, b]); + await sub.cancel(); + }); + + test('empty ratingKeys delivers nothing', () async { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => {}, + onEvent: received.add, + ); + + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); + notifier.notify(_FakeEvent(serverId: 's2', ratingKey: '2')); + await _settle(); + + expect(received, isEmpty); + await sub.cancel(); + }); + + test('ratingKeys filter delivers direct hits', () async { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => {'42'}, + onEvent: received.add, + ); + + final hit = _FakeEvent(serverId: 's1', ratingKey: '42'); + final miss = _FakeEvent(serverId: 's1', ratingKey: '99'); + notifier.notify(hit); + notifier.notify(miss); + await _settle(); + + expect(received, [hit]); + await sub.cancel(); + }); + + test('ratingKeys filter delivers parent-chain hits', () async { + // Event for an episode whose parent chain includes the show ratingKey. + // The screen tracks the show ratingKey, so it should receive the event. + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => {'show123'}, + onEvent: received.add, + ); + + final episode = _FakeEvent(serverId: 's1', ratingKey: 'episode456', parentChain: ['season789', 'show123']); + notifier.notify(episode); + await _settle(); + + expect(received, [episode]); + await sub.cancel(); + }); + + test('filters re-evaluate on each event (dynamic getters)', () async { + var rk = {'1'}; + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => rk, + onEvent: received.add, + ); + + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '2')); + await _settle(); + expect(received.map((e) => e.ratingKey).toList(), ['1']); + + // Change the filter set; the next event should be evaluated against it. + rk = {'2'}; + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '2')); + await _settle(); + expect(received.map((e) => e.ratingKey).toList(), ['1', '2']); + + await sub.cancel(); + }); + + test('cancel stops further deliveries', () async { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => null, + onEvent: received.add, + ); + + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); + await _settle(); + expect(received, hasLength(1)); + + await sub.cancel(); + notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '2')); + await _settle(); + expect(received, hasLength(1)); + }); + + test('returns a typed StreamSubscription', () { + final sub = subscribeToHierarchicalEvents<_FakeEvent>( + notifier: notifier, + mounted: () => true, + serverId: () => null, + globalKeys: () => null, + ratingKeys: () => null, + onEvent: received.add, + ); + + expect(sub, isA>()); + sub.cancel(); + }); + }); +} diff --git a/test/mixins/mounted_set_state_mixin_test.dart b/test/mixins/mounted_set_state_mixin_test.dart new file mode 100644 index 00000000..8a2873a8 --- /dev/null +++ b/test/mixins/mounted_set_state_mixin_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mixins/mounted_set_state_mixin.dart'; + +class _Probe extends StatefulWidget { + const _Probe({this.onState}); + final void Function(_ProbeState)? onState; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> with MountedSetStateMixin<_Probe> { + int counter = 0; + int builds = 0; + + @override + void initState() { + super.initState(); + widget.onState?.call(this); + } + + @override + Widget build(BuildContext context) { + builds++; + return Text('count=$counter', textDirection: TextDirection.ltr); + } +} + +void main() { + group('MountedSetStateMixin', () { + testWidgets('setStateIfMounted runs the callback and triggers rebuild while mounted', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + + final initialBuilds = state.builds; + state.setStateIfMounted(() => state.counter = 5); + await tester.pump(); + + expect(state.counter, 5); + expect(state.builds, greaterThan(initialBuilds)); + expect(find.text('count=5'), findsOneWidget); + }); + + testWidgets('setStateIfMounted is a no-op after the widget is unmounted', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + + // Unmount by replacing the widget tree. + await tester.pumpWidget(const SizedBox.shrink()); + + expect(state.mounted, isFalse); + final buildsBefore = state.builds; + + // Should not throw and should not invoke setState (which would assert on + // an unmounted state in debug mode). + expect(() => state.setStateIfMounted(() => state.counter = 99), returnsNormally); + + // The callback ran is irrelevant; what matters is no setState fired. + // counter stays at its previous value because the callback was skipped. + expect(state.counter, 0); + expect(state.builds, buildsBefore); + }); + + testWidgets('setStateIfMounted callback is invoked exactly once per call when mounted', (tester) async { + late _ProbeState state; + await tester.pumpWidget(_Probe(onState: (s) => state = s)); + + var calls = 0; + state.setStateIfMounted(() => calls++); + await tester.pump(); + + expect(calls, 1); + }); + }); +} diff --git a/test/mixins/refreshable_test.dart b/test/mixins/refreshable_test.dart new file mode 100644 index 00000000..6bcdaf1d --- /dev/null +++ b/test/mixins/refreshable_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mixins/refreshable.dart'; + +class _RefreshProbe extends StatefulWidget { + const _RefreshProbe({this.onState}); + final void Function(_RefreshProbeState)? onState; + + @override + State<_RefreshProbe> createState() => _RefreshProbeState(); +} + +class _RefreshProbeState extends State<_RefreshProbe> + with Refreshable, FullRefreshable, FocusableTab, SearchInputFocusable, LibraryLoadable { + int refreshCalls = 0; + int fullRefreshCalls = 0; + int focusActiveTabCalls = 0; + int focusSearchInputCalls = 0; + String? lastSearchQuery; + String? lastLibraryKey; + + @override + void initState() { + super.initState(); + widget.onState?.call(this); + } + + @override + void refresh() => refreshCalls++; + + @override + void fullRefresh() => fullRefreshCalls++; + + @override + void focusActiveTabIfReady() => focusActiveTabCalls++; + + @override + void focusSearchInput() => focusSearchInputCalls++; + + @override + void setSearchQuery(String query) => lastSearchQuery = query; + + @override + void loadLibraryByKey(String libraryGlobalKey) => lastLibraryKey = libraryGlobalKey; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class _RefreshableOnly extends StatefulWidget { + const _RefreshableOnly({this.onState}); + final void Function(_RefreshableOnlyState)? onState; + + @override + State<_RefreshableOnly> createState() => _RefreshableOnlyState(); +} + +class _RefreshableOnlyState extends State<_RefreshableOnly> with Refreshable { + int refreshCalls = 0; + + @override + void initState() { + super.initState(); + widget.onState?.call(this); + } + + @override + void refresh() => refreshCalls++; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class _PlainProbe extends StatefulWidget { + const _PlainProbe({this.onState}); + final void Function(_PlainProbeState)? onState; + + @override + State<_PlainProbe> createState() => _PlainProbeState(); +} + +class _PlainProbeState extends State<_PlainProbe> { + @override + void initState() { + super.initState(); + widget.onState?.call(this); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + group('Refreshable', () { + testWidgets('refresh() invokes the implementation', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + state.refresh(); + state.refresh(); + + expect(state.refreshCalls, 2); + }); + + testWidgets('a State mixing in Refreshable matches an `is Refreshable` check', (tester) async { + late _RefreshableOnlyState state; + await tester.pumpWidget(_RefreshableOnly(onState: (s) => state = s)); + + // This is the production usage: `if (currentState case final Refreshable r) r.refresh()`. + expect(state, isA()); + + // Drive the refresh via the interface to mirror real callers. + if (state case final Refreshable r) { + r.refresh(); + } + expect(state.refreshCalls, 1); + }); + + testWidgets('a plain State without the mixin does not match Refreshable', (tester) async { + late _PlainProbeState state; + await tester.pumpWidget(_PlainProbe(onState: (s) => state = s)); + + expect(state, isNot(isA())); + expect(state, isNot(isA())); + expect(state, isNot(isA())); + expect(state, isNot(isA())); + expect(state, isNot(isA())); + }); + }); + + group('FullRefreshable', () { + testWidgets('fullRefresh() invokes the implementation', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + if (state case final FullRefreshable f) { + f.fullRefresh(); + } + + expect(state.fullRefreshCalls, 1); + }); + + testWidgets('refresh() and fullRefresh() are independent counters', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + state.refresh(); + state.refresh(); + state.fullRefresh(); + + expect(state.refreshCalls, 2); + expect(state.fullRefreshCalls, 1); + }); + }); + + group('FocusableTab', () { + testWidgets('focusActiveTabIfReady() invokes the implementation', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + expect(state, isA()); + state.focusActiveTabIfReady(); + expect(state.focusActiveTabCalls, 1); + }); + }); + + group('SearchInputFocusable', () { + testWidgets('focusSearchInput() invokes the implementation', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + state.focusSearchInput(); + expect(state.focusSearchInputCalls, 1); + }); + + testWidgets('setSearchQuery() forwards the query argument', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + state.setSearchQuery('hello'); + expect(state.lastSearchQuery, 'hello'); + + state.setSearchQuery(''); + expect(state.lastSearchQuery, ''); + }); + }); + + group('LibraryLoadable', () { + testWidgets('loadLibraryByKey() forwards the key argument', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + state.loadLibraryByKey('server1:42'); + expect(state.lastLibraryKey, 'server1:42'); + }); + }); + + group('combined mixins', () { + testWidgets('a State can mix in all five interface mixins simultaneously', (tester) async { + late _RefreshProbeState state; + await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s)); + + expect(state, isA()); + expect(state, isA()); + expect(state, isA()); + expect(state, isA()); + expect(state, isA()); + }); + }); +} diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart new file mode 100644 index 00000000..5c3a06b0 --- /dev/null +++ b/test/providers/offline_mode_provider_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/providers/offline_mode_provider.dart'; +import 'package:plezy/services/multi_server_manager.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + // OfflineModeProvider depends on a MultiServerManager. We instantiate one with + // no connected servers — this exercises only the in-memory bookkeeping (id + // maps + status stream) and never opens an HTTP socket. Network paths + // (initialize/refresh's connectivity_plus call) are skipped: the + // MissingPluginException in tests is already swallowed by the provider's + // try/catch, so we don't drive `initialize()` here. + setUp(resetSharedPreferencesForTest); + + group('OfflineModeProvider', () { + test('with empty manager reports server-side offline at construction', () { + final manager = MultiServerManager(); + final p = OfflineModeProvider(manager); + + // Default network=true, no servers → isOffline=true (no server connection). + expect(p.hasNetworkConnection, isTrue); + expect(p.hasServerConnection, isFalse); + expect(p.isOffline, isTrue); + + p.dispose(); + manager.dispose(); + }); + + test('reads online server IDs from the manager at construction', () { + final manager = MultiServerManager(); + manager.updateServerStatus('srv-1', true); + final p = OfflineModeProvider(manager); + + expect(p.hasServerConnection, isTrue); + // Network is assumed up by default; both up → not offline. + expect(p.isOffline, isFalse); + + p.dispose(); + manager.dispose(); + }); + + test('all servers offline → hasServerConnection is false', () { + final manager = MultiServerManager(); + manager.updateServerStatus('srv-1', false); + manager.updateServerStatus('srv-2', false); + final p = OfflineModeProvider(manager); + + expect(p.hasServerConnection, isFalse); + expect(p.isOffline, isTrue); + + p.dispose(); + manager.dispose(); + }); + + test('dispose without initialize is safe (no subscriptions to cancel)', () { + final manager = MultiServerManager(); + final p = OfflineModeProvider(manager); + + // Both subscriptions are null since initialize() was never called. + // dispose must tolerate this without throwing. + expect(p.dispose, returnsNormally); + manager.dispose(); + }); + + test('dispose marks provider as disposed; later notifies are no-ops', () { + final manager = MultiServerManager(); + final p = OfflineModeProvider(manager); + + p.dispose(); + // After dispose, the disposable mixin guards against post-dispose notify. + // We can't call private safeNotifyListeners, but `isDisposed` reflects state. + expect(p.isDisposed, isTrue); + + manager.dispose(); + }); + + test('OfflineModeSource interface contract: isOffline is exposed', () { + final manager = MultiServerManager(); + manager.updateServerStatus('srv', true); + final p = OfflineModeProvider(manager); + + // The provider implements OfflineModeSource — its isOffline getter is the + // sole observable surface for downstream consumers. + expect(p.isOffline, isFalse); + + p.dispose(); + manager.dispose(); + }); + }); +} diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart new file mode 100644 index 00000000..d90d0d97 --- /dev/null +++ b/test/providers/playback_state_provider_test.dart @@ -0,0 +1,219 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/play_queue_response.dart'; +import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; + +PlexMetadata _item(String ratingKey, int playQueueItemID) => + PlexMetadata(ratingKey: ratingKey, playQueueItemID: playQueueItemID, title: 'Episode $ratingKey'); + +PlayQueueResponse _queue({ + int playQueueID = 1, + int? selectedItemID, + bool shuffled = false, + int? totalCount, + int? size, + List? items, +}) { + return PlayQueueResponse( + playQueueID: playQueueID, + playQueueSelectedItemID: selectedItemID, + playQueueShuffled: shuffled, + playQueueTotalCount: totalCount, + playQueueVersion: 1, + size: size, + items: items, + ); +} + +void main() { + group('PlaybackStateProvider', () { + test('starts in idle state with no queue', () { + final p = PlaybackStateProvider(); + expect(p.isQueueActive, isFalse); + expect(p.isPlaylistActive, isFalse); + expect(p.isShuffleActive, isFalse); + expect(p.playQueueId, isNull); + expect(p.currentPlayQueueItemID, isNull); + expect(p.shuffleContextKey, isNull); + expect(p.loadedItems, isEmpty); + p.dispose(); + }); + + test('setPlaybackFromPlayQueue populates state and notifies', () async { + final p = PlaybackStateProvider(); + var notified = 0; + p.addListener(() => notified++); + + final items = [_item('100', 1001), _item('101', 1002), _item('102', 1003)]; + final response = _queue(playQueueID: 42, selectedItemID: 1002, shuffled: true, totalCount: 3, items: items); + + await p.setPlaybackFromPlayQueue(response, 'show-key'); + + expect(p.playQueueId, 42); + expect(p.currentPlayQueueItemID, 1002); + expect(p.isShuffleActive, isTrue); + expect(p.isPlaylistActive, isTrue); + expect(p.isQueueActive, isTrue); + expect(p.shuffleContextKey, 'show-key'); + expect(p.loadedItems, hasLength(3)); + expect(notified, 1); + + p.dispose(); + }); + + test('totalCount falls back to size then items length', () async { + final p = PlaybackStateProvider(); + final items = [_item('a', 1), _item('b', 2)]; + + // totalCount missing, size present → uses size + await p.setPlaybackFromPlayQueue(_queue(size: 7, items: items), null); + expect(p.loadedItems, hasLength(2)); + // The fallback is internal but observable via getNextEpisode at end-of-window: + // size=7 means the window isn't at the end, so the loop guard differs. + + // Reset: totalCount=null, size=null, items length used. + p.clearShuffle(); + await p.setPlaybackFromPlayQueue(_queue(items: items), null); + expect(p.loadedItems, hasLength(2)); + + p.dispose(); + }); + + test('clearShuffle resets all state and notifies', () async { + final p = PlaybackStateProvider(); + final items = [_item('a', 1), _item('b', 2)]; + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 99, selectedItemID: 1, totalCount: 2, items: items), + 'context-1', + ); + expect(p.isQueueActive, isTrue); + + var notified = 0; + p.addListener(() => notified++); + + p.clearShuffle(); + expect(p.isQueueActive, isFalse); + expect(p.isPlaylistActive, isFalse); + expect(p.isShuffleActive, isFalse); + expect(p.playQueueId, isNull); + expect(p.currentPlayQueueItemID, isNull); + expect(p.shuffleContextKey, isNull); + expect(p.loadedItems, isEmpty); + expect(notified, 1); + + p.dispose(); + }); + + test('setCurrentItem updates id only when in queue mode', () async { + final p = PlaybackStateProvider(); + + // Not in queue mode → no-op + var notified = 0; + p.addListener(() => notified++); + p.setCurrentItem(_item('a', 5)); + expect(p.currentPlayQueueItemID, isNull); + expect(notified, 0); + + // Enter queue mode + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 1, selectedItemID: 1001, totalCount: 1, items: [_item('a', 1001)]), + null, + ); + // setPlaybackFromPlayQueue notifies once + final preNotify = notified; + + p.setCurrentItem(_item('b', 2002)); + expect(p.currentPlayQueueItemID, 2002); + expect(notified, preNotify + 1); + + // Item without playQueueItemID → no update, no notify + p.setCurrentItem(PlexMetadata(ratingKey: 'd')); + expect(p.currentPlayQueueItemID, 2002); + + p.dispose(); + }); + + test('getNextEpisode returns next loaded item when current is mid-window', () async { + final p = PlaybackStateProvider(); + final items = [_item('a', 1001), _item('b', 1002), _item('c', 1003)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null); + + final next = await p.getNextEpisode('b'); + expect(next, isNotNull); + expect(next!.ratingKey, 'c'); + expect(next.playQueueItemID, 1003); + + // currentPlayQueueItemID is NOT updated by getNextEpisode (setCurrentItem does that). + expect(p.currentPlayQueueItemID, 1002); + + p.dispose(); + }); + + test('getNextEpisode returns null at end of queue without loop', () async { + final p = PlaybackStateProvider(); + final items = [_item('a', 1001), _item('b', 1002)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 2, items: items), null); + + final next = await p.getNextEpisode('b'); + expect(next, isNull); + + p.dispose(); + }); + + test('getNextEpisode with no queue returns null (sequential mode)', () async { + final p = PlaybackStateProvider(); + final next = await p.getNextEpisode('any-key'); + expect(next, isNull); + p.dispose(); + }); + + test('getPreviousEpisode returns previous loaded item when current is mid-window', () async { + final p = PlaybackStateProvider(); + final items = [_item('a', 1001), _item('b', 1002), _item('c', 1003)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null); + + final prev = await p.getPreviousEpisode('b'); + expect(prev, isNotNull); + expect(prev!.ratingKey, 'a'); + expect(prev.playQueueItemID, 1001); + + p.dispose(); + }); + + test('getPreviousEpisode at index 0 returns null', () async { + final p = PlaybackStateProvider(); + final items = [_item('a', 1001), _item('b', 1002)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: items), null); + + final prev = await p.getPreviousEpisode('a'); + expect(prev, isNull); + + p.dispose(); + }); + + test('getPreviousEpisode without queue mode returns null', () async { + final p = PlaybackStateProvider(); + final prev = await p.getPreviousEpisode('any-key'); + expect(prev, isNull); + p.dispose(); + }); + + test('loadedItems getter is unmodifiable', () async { + final p = PlaybackStateProvider(); + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 1, selectedItemID: 1, totalCount: 1, items: [_item('a', 1)]), + null, + ); + expect(() => p.loadedItems.add(_item('mutated', 999)), throwsUnsupportedError); + p.dispose(); + }); + + test('safeNotifyListeners after dispose is a no-op', () async { + final p = PlaybackStateProvider(); + p.dispose(); + // clearShuffle and setPlaybackFromPlayQueue both notify; must not throw. + p.clearShuffle(); + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, totalCount: 1, items: [_item('a', 1)]), null); + }); + }); +} diff --git a/test/providers/user_profile_provider_test.dart b/test/providers/user_profile_provider_test.dart new file mode 100644 index 00000000..77e09305 --- /dev/null +++ b/test/providers/user_profile_provider_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex_home.dart'; +import 'package:plezy/models/plex_home_user.dart'; +import 'package:plezy/providers/user_profile_provider.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +PlexHomeUser _user({ + required int id, + String? uuid, + String title = 'Title', + bool admin = false, + bool protected = false, +}) { + return PlexHomeUser( + id: id, + uuid: uuid ?? 'uuid-$id', + title: title, + username: null, + email: null, + friendlyName: null, + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: admin, + guest: false, + protected: protected, + ); +} + +PlexHome _home({List? users}) { + return PlexHome( + id: 1, + name: 'My Home', + guestUserID: null, + guestUserUUID: '', + guestEnabled: false, + subscription: false, + users: + users ?? + [_user(id: 1, uuid: 'admin-uuid', title: 'Admin', admin: true), _user(id: 2, uuid: 'kid-uuid', title: 'Kid')], + ); +} + +void main() { + setUp(resetSharedPreferencesForTest); + + group('UserProfileProvider', () { + test('starts with all-null state and no error', () { + final p = UserProfileProvider(); + expect(p.home, isNull); + expect(p.currentUser, isNull); + expect(p.profileSettings, isNull); + expect(p.isLoading, isFalse); + expect(p.error, isNull); + expect(p.hasMultipleUsers, isFalse); + expect(p.needsInitialProfileSelection, isFalse); + p.dispose(); + }); + + test('initialize loads cached home users from SharedPreferences', () async { + // Pre-seed home users cache directly. + final storage = await StorageService.getInstance(); + await storage.saveHomeUsersCache(_home().toJson()); + + final p = UserProfileProvider(); + var notified = 0; + p.addListener(() => notified++); + + await p.initialize(); + + expect(p.home, isNotNull); + expect(p.home!.users, hasLength(2)); + expect(p.home!.name, 'My Home'); + expect(p.hasMultipleUsers, isTrue); + // No current user UUID stored, so currentUser is null and selection is needed. + expect(p.currentUser, isNull); + expect(p.needsInitialProfileSelection, isTrue); + // _loadCachedData notifies once. + expect(notified, greaterThanOrEqualTo(1)); + + p.dispose(); + }); + + test('initialize resolves currentUser from stored UUID', () async { + final storage = await StorageService.getInstance(); + await storage.saveHomeUsersCache(_home().toJson()); + await storage.saveCurrentUserUUID('kid-uuid'); + + final p = UserProfileProvider(); + await p.initialize(); + + expect(p.currentUser, isNotNull); + expect(p.currentUser!.uuid, 'kid-uuid'); + expect(p.currentUser!.title, 'Kid'); + // Once a user is selected, no initial selection needed. + expect(p.needsInitialProfileSelection, isFalse); + + p.dispose(); + }); + + test('hasMultipleUsers reflects the home', () async { + final storage = await StorageService.getInstance(); + await storage.saveHomeUsersCache(_home(users: [_user(id: 1, admin: true)]).toJson()); + + final p = UserProfileProvider(); + await p.initialize(); + expect(p.home!.users, hasLength(1)); + expect(p.hasMultipleUsers, isFalse); + + p.dispose(); + }); + + test('needsInitialProfileSelection is false when no home loaded', () async { + final p = UserProfileProvider(); + await p.initialize(); // No cache, no token → home stays null. + expect(p.home, isNull); + expect(p.needsInitialProfileSelection, isFalse); + p.dispose(); + }); + + test('logout with no services initialized is a no-op', () async { + final p = UserProfileProvider(); + // Without initialize, _storageService is null → logout returns early. + await p.logout(); + expect(p.home, isNull); + expect(p.currentUser, isNull); + expect(p.error, isNull); + p.dispose(); + }); + + test('logout clears all state and notifies', () async { + final storage = await StorageService.getInstance(); + await storage.saveHomeUsersCache(_home().toJson()); + await storage.saveCurrentUserUUID('admin-uuid'); + + final p = UserProfileProvider(); + await p.initialize(); + expect(p.home, isNotNull); + expect(p.currentUser, isNotNull); + + var notified = 0; + p.addListener(() => notified++); + + await p.logout(); + expect(p.home, isNull); + expect(p.currentUser, isNull); + expect(p.profileSettings, isNull); + expect(p.error, isNull); + // Setting loading true/false + clearing state fires notifications. + expect(notified, greaterThanOrEqualTo(1)); + + p.dispose(); + }); + + test('refreshCurrentUser is a no-op when currentUser is null', () async { + final p = UserProfileProvider(); + // No initialize, no current user → method short-circuits without touching network. + var notified = 0; + p.addListener(() => notified++); + await p.refreshCurrentUser(); + expect(notified, 0); + expect(p.currentUser, isNull); + p.dispose(); + }); + + test('setDataInvalidationCallback stores the callback without side effects', () { + final p = UserProfileProvider(); + // Should not throw and should not notify. + var notified = 0; + p.addListener(() => notified++); + p.setDataInvalidationCallback((_) async {}); + expect(notified, 0); + // Clearing it should also be safe. + p.setDataInvalidationCallback(null); + expect(notified, 0); + p.dispose(); + }); + + test('safeNotifyListeners after dispose does not throw', () async { + final p = UserProfileProvider(); + p.dispose(); + // logout uses safeNotifyListeners — must not throw post-dispose. + // Without initialized services it short-circuits, so no failure either. + await p.logout(); + }); + }); +} diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart new file mode 100644 index 00000000..6266e300 --- /dev/null +++ b/test/services/download_storage_service_test.dart @@ -0,0 +1,605 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../test_helpers/prefs.dart'; + +/// In-test fake PathProviderPlatform that points all directories at a real +/// on-disk temp folder. Required because the production service calls +/// [getApplicationDocumentsDirectory] / [getApplicationSupportDirectory] — +/// both of which fail outside an app context unless the platform interface +/// is mocked. +class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { + _FakePathProvider(this.root); + + final Directory root; + String get _docs => p.join(root.path, 'documents'); + String get _support => p.join(root.path, 'support'); + String get _cache => p.join(root.path, 'cache'); + String get _temp => p.join(root.path, 'temp'); + + @override + Future getApplicationDocumentsPath() async => _ensure(_docs); + + @override + Future getApplicationSupportPath() async => _ensure(_support); + + @override + Future getApplicationCachePath() async => _ensure(_cache); + + @override + Future getTemporaryPath() async => _ensure(_temp); + + String _ensure(String dir) { + Directory(dir).createSync(recursive: true); + return dir; + } +} + +void main() { + late Directory tmpRoot; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + tmpRoot = await Directory.systemTemp.createTemp('dss_test_'); + PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + }); + + tearDown(() async { + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + if (await tmpRoot.exists()) { + await tmpRoot.delete(recursive: true); + } + }); + + // ============================================================ + // Singleton + reset + // ============================================================ + + group('singleton', () { + test('instance returns same object across calls', () { + final a = DownloadStorageService.instance; + final b = DownloadStorageService.instance; + expect(identical(a, b), isTrue); + }); + + test('resetForTesting yields a fresh instance', () { + final first = DownloadStorageService.instance; + DownloadStorageService.resetForTesting(); + final second = DownloadStorageService.instance; + expect(identical(first, second), isFalse); + }); + }); + + // ============================================================ + // SAF mode (Android-only). On host (macOS/Linux) it is always false. + // ============================================================ + + group('SAF mode', () { + test('isUsingSaf is false on the host (non-Android)', () async { + final settings = await SettingsService.getInstance(); + // Even with SAF-shaped settings, host platform check must short-circuit. + await settings.write(SettingsService.customDownloadPathType, 'saf'); + await settings.write( + SettingsService.customDownloadPath, + 'content://com.android.externalstorage.documents/tree/primary%3ADownload', + ); + + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + expect(Platform.isAndroid, isFalse, reason: 'host preflight: this test is meaningless on Android'); + expect(dss.isUsingSaf, isFalse); + expect(dss.safBaseUri, isNull); + }); + + test('isSafUri detects content:// URIs regardless of platform', () { + final dss = DownloadStorageService.instance; + expect(dss.isSafUri('content://com.android.externalstorage.documents/tree/primary%3ADownload'), isTrue); + expect(dss.isSafUri('/var/mobile/Containers/Data/Application/abc/Documents/downloads/x.mkv'), isFalse); + expect(dss.isSafUri('file:///tmp/foo'), isFalse); + }); + }); + + // ============================================================ + // Default download directory + custom-path switching + // ============================================================ + + group('downloads directory resolution', () { + test('defaults to /downloads on desktop hosts', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + expect(dir.existsSync(), isTrue); + // _getBaseAppDir returns getApplicationSupportDirectory() on desktop. + expect(dir.path, p.join(p.join(tmpRoot.path, 'support'), 'downloads')); + expect(dss.isUsingCustomPath(), isFalse); + }); + + test('honors a writable custom file-type path', () async { + final settings = await SettingsService.getInstance(); + final customDir = Directory(p.join(tmpRoot.path, 'custom-downloads'))..createSync(recursive: true); + + await settings.write(SettingsService.customDownloadPathType, 'file'); + await settings.write(SettingsService.customDownloadPath, customDir.path); + + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + expect(dir.path, customDir.path); + expect(dss.isUsingCustomPath(), isTrue); + + final display = await dss.getCurrentDownloadPathDisplay(); + expect(display, customDir.path); + }); + + test('falls back to default when custom path is non-writable', () async { + final settings = await SettingsService.getInstance(); + + // Point the custom path to a path inside a read-only parent. + final readOnlyParent = Directory(p.join(tmpRoot.path, 'readonly'))..createSync(recursive: true); + try { + // Make parent unwritable so writing inside fails. Skip if the OS + // ignores the chmod (e.g. when running as root). + await Process.run('chmod', ['000', readOnlyParent.path]); + final blocked = p.join(readOnlyParent.path, 'forbidden'); + await settings.write(SettingsService.customDownloadPathType, 'file'); + await settings.write(SettingsService.customDownloadPath, blocked); + + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + // Either the chmod worked → we fall back to default, + // or it didn't → we used the custom path. Both are valid; the + // important contract is that the call doesn't throw. + expect(dir.existsSync(), isTrue); + if (dir.path == blocked) { + // chmod was a no-op (root or a filesystem that ignores it). Skip the + // strict assertion — the fallback branch only runs when writes fail. + return; + } + expect(dir.path, p.join(p.join(tmpRoot.path, 'support'), 'downloads')); + } finally { + await Process.run('chmod', ['755', readOnlyParent.path]); + } + }); + + test('refreshCustomPath picks up settings changes', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + expect(dss.isUsingCustomPath(), isFalse); + + final newDir = Directory(p.join(tmpRoot.path, 'after-refresh'))..createSync(recursive: true); + await settings.write(SettingsService.customDownloadPathType, 'file'); + await settings.write(SettingsService.customDownloadPath, newDir.path); + + await dss.refreshCustomPath(); + expect(dss.isUsingCustomPath(), isTrue); + + final dir = await dss.getDownloadsDirectory(); + expect(dir.path, newDir.path); + }); + }); + + // ============================================================ + // Artwork directory + // ============================================================ + + group('artwork directory', () { + test('initializes alongside support directory by default and caches sync path', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final artworkDir = await dss.getArtworkDirectory(); + expect(artworkDir.existsSync(), isTrue); + expect(artworkDir.path, p.join(tmpRoot.path, 'support', 'artwork')); + // After initialize() the sync path getter is populated. + expect(dss.artworkDirectoryPath, artworkDir.path); + }); + + test('places artwork next to a custom downloads path', () async { + final settings = await SettingsService.getInstance(); + + final customDir = Directory(p.join(tmpRoot.path, 'media-root', 'downloads'))..createSync(recursive: true); + await settings.write(SettingsService.customDownloadPathType, 'file'); + await settings.write(SettingsService.customDownloadPath, customDir.path); + + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final artworkDir = await dss.getArtworkDirectory(); + expect(artworkDir.path, p.join(customDir.parent.path, 'artwork')); + expect(artworkDir.existsSync(), isTrue); + }); + + test('getArtworkPathSync returns null before initialize, deduplicates after', () async { + final dss = DownloadStorageService.instance; + // Before initialize() the sync getter is null. + expect(dss.artworkDirectoryPath, isNull); + expect(dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'), isNull); + + final settings = await SettingsService.getInstance(); + await dss.initialize(settings); + + final p1 = dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'); + final p2 = dss.getArtworkPathSync('srv', '/library/metadata/1/thumb'); + final p3 = dss.getArtworkPathSync('srv', '/library/metadata/2/thumb'); + expect(p1, isNotNull); + // Same input → same path (MD5 of `serverId:thumbPath`). + expect(p1, p2); + expect(p1, isNot(p3)); + expect(p1!.endsWith('.jpg'), isTrue); + }); + + test('async + sync artwork paths agree for the same input', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final asyncPath = await dss.getArtworkPathFromThumb('srv', '/library/metadata/9/thumb'); + final syncPath = dss.getArtworkPathSync('srv', '/library/metadata/9/thumb'); + expect(asyncPath, syncPath); + }); + + test('artworkExists reflects on-disk state', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + expect(await dss.artworkExists('srv', '/thumb/1'), isFalse); + + final filePath = await dss.getArtworkPathFromThumb('srv', '/thumb/1'); + await File(filePath).writeAsString('fake-artwork'); + expect(await dss.artworkExists('srv', '/thumb/1'), isTrue); + }); + }); + + // ============================================================ + // Path resolution helpers (relative <-> absolute) + // ============================================================ + + group('toRelativePath / toAbsolutePath', () { + test('strips a single base-dir prefix to make a path relative', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + // Compute the support base the production code uses. + final base = p.join(tmpRoot.path, 'support'); + final abs = p.join(base, 'downloads', 'srv', '42', 'video.mp4'); + final rel = await dss.toRelativePath(abs); + expect(rel, p.join('downloads', 'srv', '42', 'video.mp4')); + }); + + test('returns input unchanged for absolute paths outside the base dir', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + // Content URIs and non-base absolute paths must round-trip untouched — + // the production code only strips paths that literally start with the + // base dir. + const uri = '/Volumes/External/Movies/x.mkv'; + expect(await dss.toRelativePath(uri), uri); + }); + + test('returns the input unchanged when not under the base dir', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + const foreign = '/some/other/place/file.mkv'; + expect(await dss.toRelativePath(foreign), foreign); + }); + + test('toAbsolutePath joins relative paths against the base dir', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final base = p.join(tmpRoot.path, 'support'); + final abs = await dss.toAbsolutePath(p.join('downloads', 'srv', '1', 'video.mp4')); + expect(abs, p.join(base, 'downloads', 'srv', '1', 'video.mp4')); + }); + + test('toAbsolutePath returns absolute paths unchanged', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + const already = '/Volumes/Data/Movies/m.mkv'; + expect(await dss.toAbsolutePath(already), already); + }); + + test('toRelativePath then toAbsolutePath round-trips', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final base = p.join(tmpRoot.path, 'support'); + final abs = p.join(base, 'downloads', 'srv', '7', 'video.mp4'); + final rel = await dss.toRelativePath(abs); + final back = await dss.toAbsolutePath(rel); + expect(back, abs); + }); + }); + + // ============================================================ + // ensureAbsolutePath / getReadablePath + // ============================================================ + + group('ensureAbsolutePath', () { + test('keeps an existing absolute path that points at a real file', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + final filePath = p.join(dir.path, 'concrete.mkv'); + await File(filePath).writeAsString('hi'); + + final resolved = await dss.ensureAbsolutePath(filePath); + expect(resolved, filePath); + }); + + test('joins a relative path against the base dir and finds the file', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + final filePath = p.join(dir.path, 'rel-found.mkv'); + await File(filePath).writeAsString('ok'); + + final base = p.join(tmpRoot.path, 'support'); + final relativeStored = p.relative(filePath, from: base); + final resolved = await dss.ensureAbsolutePath(relativeStored); + expect(resolved, filePath); + }); + + test('recovers from a doubled base-dir prefix when the recovered file exists', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final base = p.join(tmpRoot.path, 'support'); + final realDir = Directory(p.join(base, 'downloads', 'srv-x'))..createSync(recursive: true); + final realFile = File(p.join(realDir.path, 'recovered.mkv')); + await realFile.writeAsString('found'); + + // Simulate the bug: the stored absolute path doubles the base dir. + final corrupted = '$base$base${p.separator}downloads${p.separator}srv-x${p.separator}recovered.mkv'; + final resolved = await dss.ensureAbsolutePath(corrupted); + expect(resolved, realFile.path); + }); + + test('falls back to the first candidate when nothing exists on disk', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + // Relative path that won't resolve to anything that exists. The fallback + // returns the toAbsolutePath() candidate (joined under the base dir). + const stored = 'downloads/missing/never.mkv'; + final resolved = await dss.ensureAbsolutePath(stored); + final expected = p.join(tmpRoot.path, 'support', stored); + expect(resolved, expected); + }); + }); + + group('getReadablePath', () { + test('passes content:// URIs through unchanged', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + const uri = 'content://com.android.externalstorage.documents/tree/primary%3ADownload/document/x'; + expect(await dss.getReadablePath(uri), uri); + }); + + test('falls back to ensureAbsolutePath for non-content paths', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + final realFile = File(p.join(dir.path, 'readable.mkv')); + await realFile.writeAsString('x'); + + final relStored = p.relative(realFile.path, from: p.join(tmpRoot.path, 'support')); + final readable = await dss.getReadablePath(relStored); + expect(readable, realFile.path); + }); + }); + + // ============================================================ + // SAF path-component helpers (no platform calls — pure formatting) + // ============================================================ + + group('SAF path components & names', () { + test('movie components/filename use sanitized "Title (Year)"', () async { + final dss = DownloadStorageService.instance; + // Movies need a title and may have a year. + final movie = _movie(title: 'My/Movie:Name?', year: 2023); + + expect(dss.getMovieSafPathComponents(movie), ['Movies', 'MyMovieName (2023)']); + expect(dss.getMovieSafFileName(movie, 'mkv'), 'MyMovieName (2023).mkv'); + }); + + test('movie without year: no parenthesized suffix', () async { + final dss = DownloadStorageService.instance; + final movie = _movie(title: 'Untitled'); + expect(dss.getMovieSafPathComponents(movie), ['Movies', 'Untitled']); + expect(dss.getMovieSafFileName(movie, 'mp4'), 'Untitled.mp4'); + }); + + test('episode components use show + season + S{XX}E{XX} - {Title}', () async { + final dss = DownloadStorageService.instance; + final episode = _episode( + showTitle: 'My Show', + showYear: 2010, + seasonNumber: 1, + episodeNumber: 5, + episodeTitle: 'Pilot:Pt 1', + ); + + expect(dss.getEpisodeSafPathComponents(episode), ['TV Shows', 'My Show (2010)', 'Season 01']); + expect(dss.getEpisodeSafFileName(episode, 'mkv'), 'S01E05 - PilotPt 1.mkv'); + expect(dss.getEpisodeSafBaseName(episode), 'S01E05 - PilotPt 1'); + }); + + test('show components default to metadata title when grandparentTitle is absent', () async { + final dss = DownloadStorageService.instance; + final show = _show(title: 'Solo Show', year: 2024); + expect(dss.getShowSafPathComponents(show), ['TV Shows', 'Solo Show (2024)']); + }); + + test('season components use season.index for the season number', () async { + final dss = DownloadStorageService.instance; + final season = _season(showTitle: 'Show A', showYear: 2019, seasonNumber: 3); + expect(dss.getSeasonSafPathComponents(season), ['TV Shows', 'Show A (2019)', 'Season 03']); + }); + + test('explicit showYear overrides episode.year for the show folder', () async { + final dss = DownloadStorageService.instance; + final episode = _episode( + showTitle: 'Year Mismatch', + showYear: 2010, // metadata.year on episode + seasonNumber: 2, + episodeNumber: 1, + episodeTitle: 'Title', + ); + // Pass a different show year explicitly. + expect(dss.getEpisodeSafPathComponents(episode, showYear: 2008), [ + 'TV Shows', + 'Year Mismatch (2008)', + 'Season 02', + ]); + }); + }); + + // ============================================================ + // Real on-disk media directory helpers + // ============================================================ + + group('media directories on disk', () { + test('getMediaDirectory creates serverId/ratingKey under downloads', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getMediaDirectory('srv-1', '42'); + expect(dir.existsSync(), isTrue); + final downloads = await dss.getDownloadsDirectory(); + expect(dir.path, p.join(downloads.path, 'srv-1', '42')); + }); + + test('getMovieDirectory + getMovieVideoPath produce consistent output', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final movie = _movie(title: 'Big Movie', year: 2020); + final dir = await dss.getMovieDirectory(movie); + final video = await dss.getMovieVideoPath(movie, 'mkv'); + expect(dir.existsSync(), isTrue); + expect(p.dirname(video), dir.path); + expect(p.basename(video), 'Big Movie (2020).mkv'); + }); + + test('getEpisodeVideoPath + thumbnail path share the same season directory', () async { + final settings = await SettingsService.getInstance(); + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final episode = _episode( + showTitle: 'Demo Show', + showYear: 2015, + seasonNumber: 2, + episodeNumber: 4, + episodeTitle: 'Pilot', + ); + + final video = await dss.getEpisodeVideoPath(episode, 'mkv'); + final thumb = await dss.getEpisodeThumbnailPath(episode); + expect(p.dirname(video), p.dirname(thumb)); + expect(p.basename(video), 'S02E04 - Pilot.mkv'); + expect(p.basename(thumb), 'S02E04 - Pilot.jpg'); + + final subsDir = await dss.getEpisodeSubtitlesDirectory(episode); + expect(subsDir.existsSync(), isTrue); + expect(p.basename(subsDir.path), 'S02E04 - Pilot_subs'); + }); + }); + + // ============================================================ + // DownloadStorageException + // ============================================================ + + group('DownloadStorageException', () { + test('toString embeds message, path, and cause', () { + final ex = DownloadStorageException('boom', '/tmp/x', StateError('inner')); + final s = ex.toString(); + expect(s, contains('boom')); + expect(s, contains('/tmp/x')); + expect(s, contains('inner')); + }); + }); +} + +// ============================================================ +// PlexMetadata fixtures (only the fields the SUT actually reads) +// ============================================================ + +PlexMetadata _movie({required String title, int? year}) { + return PlexMetadata(ratingKey: 'm-${title.hashCode}', type: 'movie', title: title, year: year); +} + +PlexMetadata _show({required String title, int? year}) { + return PlexMetadata(ratingKey: 's-${title.hashCode}', type: 'show', title: title, year: year); +} + +PlexMetadata _season({required String showTitle, int? showYear, required int seasonNumber}) { + return PlexMetadata( + ratingKey: 'season-$showTitle-$seasonNumber', + type: 'season', + title: 'Season $seasonNumber', + grandparentTitle: showTitle, + year: showYear, + index: seasonNumber, + ); +} + +PlexMetadata _episode({ + required String showTitle, + int? showYear, + required int seasonNumber, + required int episodeNumber, + required String episodeTitle, +}) { + return PlexMetadata( + ratingKey: 'ep-$showTitle-$seasonNumber-$episodeNumber', + type: 'episode', + title: episodeTitle, + grandparentTitle: showTitle, + year: showYear, + parentIndex: seasonNumber, + index: episodeNumber, + ); +} diff --git a/test/services/server_registry_test.dart b/test/services/server_registry_test.dart new file mode 100644 index 00000000..d6a00e4f --- /dev/null +++ b/test/services/server_registry_test.dart @@ -0,0 +1,276 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/services/server_registry.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +PlexConnection _conn({ + String protocol = 'https', + String address = '192.0.2.1', + int port = 32400, + String? uri, + bool local = false, + bool relay = false, + bool ipv6 = false, +}) { + return PlexConnection( + protocol: protocol, + address: address, + port: port, + uri: uri ?? '$protocol://$address.plex.direct:$port', + local: local, + relay: relay, + ipv6: ipv6, + ); +} + +PlexServer _server({ + String name = 'Home Server', + String clientIdentifier = 'srv-1', + String accessToken = 'tok-1', + bool owned = true, + String? product = 'Plex Media Server', + String? platform = 'Linux', + bool presence = true, + List? connections, +}) { + return PlexServer( + name: name, + clientIdentifier: clientIdentifier, + accessToken: accessToken, + connections: connections ?? [_conn()], + owned: owned, + product: product, + platform: platform, + lastSeenAt: DateTime.utc(2025, 1, 1, 12, 0, 0), + presence: presence, + ); +} + +void main() { + setUp(resetSharedPreferencesForTest); + + late StorageService storage; + late ServerRegistry registry; + + Future bootstrap() async { + storage = await StorageService.getInstance(); + registry = ServerRegistry(storage); + } + + group('getServers', () { + test('returns empty list when no servers JSON is set', () async { + await bootstrap(); + expect(await registry.getServers(), isEmpty); + }); + + test('returns empty list for empty-string JSON', () async { + await bootstrap(); + await storage.saveServersListJson(''); + expect(await registry.getServers(), isEmpty); + }); + + test('returns empty list when stored JSON is malformed', () async { + await bootstrap(); + await storage.saveServersListJson('not-valid-json'); + // Corrupt JSON is logged and treated as no servers, NOT thrown. + expect(await registry.getServers(), isEmpty); + }); + + test('parses a list of servers from saved JSON', () async { + await bootstrap(); + final s1 = _server(clientIdentifier: 'a'); + final s2 = _server(clientIdentifier: 'b', name: 'Other'); + await registry.saveServers([s1, s2]); + + final loaded = await registry.getServers(); + expect(loaded.map((s) => s.clientIdentifier).toList(), ['a', 'b']); + expect(loaded.first.name, 'Home Server'); + expect(loaded.last.name, 'Other'); + }); + }); + + group('saveServers', () { + test('overwrites stored JSON with the latest list', () async { + await bootstrap(); + await registry.saveServers([_server(clientIdentifier: 'a')]); + await registry.saveServers([_server(clientIdentifier: 'b'), _server(clientIdentifier: 'c', name: 'Cee')]); + + final loaded = await registry.getServers(); + expect(loaded.map((s) => s.clientIdentifier).toList(), ['b', 'c']); + }); + + test('saving empty list yields empty getServers', () async { + await bootstrap(); + await registry.saveServers([_server(clientIdentifier: 'a')]); + await registry.saveServers([]); + expect(await registry.getServers(), isEmpty); + }); + + test('persists JSON in a shape parseable by PlexServer.fromJson', () async { + await bootstrap(); + final s = _server(clientIdentifier: 'srv-z', name: 'Zee'); + await registry.saveServers([s]); + + final raw = storage.getServersListJson(); + expect(raw, isNotNull); + final decoded = jsonDecode(raw!) as List; + expect(decoded, hasLength(1)); + + final parsed = PlexServer.fromJson(decoded.first as Map); + expect(parsed.clientIdentifier, 'srv-z'); + expect(parsed.name, 'Zee'); + }); + }); + + group('getServer', () { + test('returns matching server', () async { + await bootstrap(); + await registry.saveServers([_server(clientIdentifier: 'a'), _server(clientIdentifier: 'b', name: 'Bee')]); + final found = await registry.getServer('b'); + expect(found, isNotNull); + expect(found!.name, 'Bee'); + }); + + test('returns null when id is unknown', () async { + await bootstrap(); + await registry.saveServers([_server(clientIdentifier: 'a')]); + expect(await registry.getServer('missing'), isNull); + }); + + test('returns null when no servers are stored', () async { + await bootstrap(); + expect(await registry.getServer('anything'), isNull); + }); + }); + + group('upsertServer', () { + test('adds a new server when id is not present', () async { + await bootstrap(); + await registry.upsertServer(_server(clientIdentifier: 'a')); + final servers = await registry.getServers(); + expect(servers, hasLength(1)); + expect(servers.first.clientIdentifier, 'a'); + }); + + test('updates an existing server in place (preserves order)', () async { + await bootstrap(); + await registry.saveServers([ + _server(clientIdentifier: 'a', name: 'Original A'), + _server(clientIdentifier: 'b', name: 'Bee'), + _server(clientIdentifier: 'c', name: 'Cee'), + ]); + + await registry.upsertServer(_server(clientIdentifier: 'b', name: 'Updated B')); + + final servers = await registry.getServers(); + expect(servers.map((s) => s.clientIdentifier).toList(), ['a', 'b', 'c']); + expect(servers[1].name, 'Updated B'); + expect(servers[0].name, 'Original A'); + }); + + test('appends new servers in insertion order', () async { + await bootstrap(); + await registry.upsertServer(_server(clientIdentifier: 'a')); + await registry.upsertServer(_server(clientIdentifier: 'b')); + await registry.upsertServer(_server(clientIdentifier: 'c')); + + final servers = await registry.getServers(); + expect(servers.map((s) => s.clientIdentifier).toList(), ['a', 'b', 'c']); + }); + }); + + group('removeServer', () { + test('removes only the matching server', () async { + await bootstrap(); + await registry.saveServers([ + _server(clientIdentifier: 'a'), + _server(clientIdentifier: 'b'), + _server(clientIdentifier: 'c'), + ]); + await registry.removeServer('b'); + final servers = await registry.getServers(); + expect(servers.map((s) => s.clientIdentifier).toList(), ['a', 'c']); + }); + + test('removing an unknown id is a no-op', () async { + await bootstrap(); + await registry.saveServers([_server(clientIdentifier: 'a')]); + await registry.removeServer('missing'); + final servers = await registry.getServers(); + expect(servers.map((s) => s.clientIdentifier).toList(), ['a']); + }); + + test('removing on empty list is a no-op', () async { + await bootstrap(); + await registry.removeServer('a'); + expect(await registry.getServers(), isEmpty); + }); + }); + + group('clearAllServers', () { + test('clears the underlying servers list JSON', () async { + await bootstrap(); + await registry.saveServers([_server(clientIdentifier: 'a'), _server(clientIdentifier: 'b')]); + + await registry.clearAllServers(); + + expect(await registry.getServers(), isEmpty); + expect(storage.getServersListJson(), isNull); + }); + }); + + group('refreshServersFromApi', () { + test('returns noToken when no Plex token is stored', () async { + await bootstrap(); + final result = await registry.refreshServersFromApi(); + expect(result, ServerRefreshResult.noToken); + }); + + test('returns noToken for empty Plex token', () async { + await bootstrap(); + await storage.savePlexToken(''); + final result = await registry.refreshServersFromApi(); + expect(result, ServerRefreshResult.noToken); + }); + }); + + group('Round-trip via raw storage', () { + test('saveServers preserves all PlexServer fields after re-read', () async { + await bootstrap(); + final original = _server( + clientIdentifier: 'rt', + name: 'Round Trip', + accessToken: 'token-rt', + owned: true, + product: 'Plex Media Server', + platform: 'Linux', + presence: true, + connections: [ + _conn(address: '198.51.100.5'), + _conn(protocol: 'http', address: '203.0.113.10'), + ], + ); + + await registry.saveServers([original]); + final loaded = (await registry.getServers()).single; + + expect(loaded.name, original.name); + expect(loaded.clientIdentifier, original.clientIdentifier); + expect(loaded.accessToken, original.accessToken); + expect(loaded.owned, original.owned); + expect(loaded.product, original.product); + expect(loaded.platform, original.platform); + expect(loaded.presence, original.presence); + // The HTTPS connection auto-generates an HTTP fallback on parse, so the + // re-read list is at least as long as what we passed in. + expect(loaded.connections.length, greaterThanOrEqualTo(original.connections.length)); + // The first persisted connection is preserved (modulo order). + final addresses = loaded.connections.map((c) => c.address).toSet(); + expect(addresses, containsAll(['198.51.100.5', '203.0.113.10'])); + }); + }); +} diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart new file mode 100644 index 00000000..e20de3d7 --- /dev/null +++ b/test/services/storage_service_test.dart @@ -0,0 +1,512 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(resetSharedPreferencesForTest); + + group('StorageService.getInstance', () { + test('returns same singleton instance', () async { + final a = await StorageService.getInstance(); + final b = await StorageService.getInstance(); + expect(identical(a, b), isTrue); + }); + + test('reset rebuilds against current SharedPreferences', () async { + final first = await StorageService.getInstance(); + await first.savePlexToken('token-1'); + BaseSharedPreferencesService.resetForTesting(); + + final second = await StorageService.getInstance(); + expect(identical(first, second), isFalse); + // Reset only the cached singleton, not the underlying prefs — values survive. + expect(second.getPlexToken(), 'token-1'); + }); + }); + + // ============================================================ + // Plex token / client identifier + // ============================================================ + + group('PlexToken & ClientIdentifier', () { + test('savePlexToken persists value', () async { + final s = await StorageService.getInstance(); + expect(s.getPlexToken(), isNull); + await s.savePlexToken('abc-123'); + expect(s.getPlexToken(), 'abc-123'); + }); + + test('saveClientIdentifier persists value', () async { + final s = await StorageService.getInstance(); + expect(s.getClientIdentifier(), isNull); + await s.saveClientIdentifier('client-xyz'); + expect(s.getClientIdentifier(), 'client-xyz'); + }); + + test('getOrCreateClientIdentifier returns existing value when set', () async { + final s = await StorageService.getInstance(); + await s.saveClientIdentifier('preset-id'); + final result = await s.getOrCreateClientIdentifier(); + expect(result, 'preset-id'); + expect(s.getClientIdentifier(), 'preset-id'); + }); + + test('getOrCreateClientIdentifier generates and persists a UUID on first call', () async { + final s = await StorageService.getInstance(); + expect(s.getClientIdentifier(), isNull); + + final generated = await s.getOrCreateClientIdentifier(); + expect(generated, isNotEmpty); + // UUIDv4 has 5 hyphen-separated segments. + expect(generated.split('-'), hasLength(5)); + expect(s.getClientIdentifier(), generated); + + // Second call returns the same value, not a new UUID. + final again = await s.getOrCreateClientIdentifier(); + expect(again, generated); + }); + + test('getOrCreateClientIdentifier replaces empty stored value', () async { + final s = await StorageService.getInstance(); + await s.saveClientIdentifier(''); + final generated = await s.getOrCreateClientIdentifier(); + expect(generated, isNotEmpty); + expect(s.getClientIdentifier(), generated); + }); + }); + + // ============================================================ + // Server endpoints (per-server URL caching) + // ============================================================ + + group('ServerEndpoint', () { + test('round-trip per server id', () async { + final s = await StorageService.getInstance(); + await s.saveServerEndpoint('srv-1', 'http://192.0.2.1:32400'); + await s.saveServerEndpoint('srv-2', 'http://198.51.100.5:32400'); + + expect(s.getServerEndpoint('srv-1'), 'http://192.0.2.1:32400'); + expect(s.getServerEndpoint('srv-2'), 'http://198.51.100.5:32400'); + expect(s.getServerEndpoint('missing'), isNull); + }); + + test('clearServerEndpoint removes only the targeted id', () async { + final s = await StorageService.getInstance(); + await s.saveServerEndpoint('srv-1', 'http://example.test'); + await s.saveServerEndpoint('srv-2', 'http://other.test'); + await s.clearServerEndpoint('srv-1'); + expect(s.getServerEndpoint('srv-1'), isNull); + expect(s.getServerEndpoint('srv-2'), 'http://other.test'); + }); + }); + + // ============================================================ + // Multi-server JSON list & order + // ============================================================ + + group('Servers list & order', () { + test('servers list JSON round-trips', () async { + final s = await StorageService.getInstance(); + expect(s.getServersListJson(), isNull); + const payload = '[{"name":"home"}]'; + await s.saveServersListJson(payload); + expect(s.getServersListJson(), payload); + }); + + test('clearServersList removes the value', () async { + final s = await StorageService.getInstance(); + await s.saveServersListJson('[{"x":1}]'); + await s.clearServersList(); + expect(s.getServersListJson(), isNull); + }); + + test('server order round-trips and clears', () async { + final s = await StorageService.getInstance(); + expect(s.getServerOrder(), isNull); + + await s.saveServerOrder(['srv-2', 'srv-1', 'srv-3']); + expect(s.getServerOrder(), ['srv-2', 'srv-1', 'srv-3']); + + await s.clearServerOrder(); + expect(s.getServerOrder(), isNull); + }); + + test('clearMultiServerData clears list + order + endpoint prefixes', () async { + final s = await StorageService.getInstance(); + await s.saveServersListJson('[{"x":1}]'); + await s.saveServerOrder(['a', 'b']); + await s.saveServerEndpoint('a', 'http://foo.test'); + await s.saveServerEndpoint('b', 'http://bar.test'); + + await s.clearMultiServerData(); + + expect(s.getServersListJson(), isNull); + expect(s.getServerOrder(), isNull); + expect(s.getServerEndpoint('a'), isNull); + expect(s.getServerEndpoint('b'), isNull); + }); + }); + + // ============================================================ + // Hidden libraries (Set persisted as JSON list) + // ============================================================ + + group('Hidden libraries', () { + test('default is empty set', () async { + final s = await StorageService.getInstance(); + expect(s.getHiddenLibraries(), isEmpty); + }); + + test('save + read round-trip', () async { + final s = await StorageService.getInstance(); + await s.saveHiddenLibraries({'lib-a', 'lib-b'}); + expect(s.getHiddenLibraries(), equals({'lib-a', 'lib-b'})); + }); + + test('overwrite replaces previous set', () async { + final s = await StorageService.getInstance(); + await s.saveHiddenLibraries({'lib-a', 'lib-b'}); + await s.saveHiddenLibraries({'lib-c'}); + expect(s.getHiddenLibraries(), equals({'lib-c'})); + }); + + test('saving empty set persists empty set (not null)', () async { + final s = await StorageService.getInstance(); + await s.saveHiddenLibraries({'x'}); + await s.saveHiddenLibraries({}); + expect(s.getHiddenLibraries(), isEmpty); + }); + + test('survives garbage JSON by returning empty set', () async { + final s = await StorageService.getInstance(); + // Write garbage directly under the key getHiddenLibraries() will read. + await s.prefs.setString('hidden_libraries', 'not-json'); + expect(s.getHiddenLibraries(), isEmpty); + }); + }); + + // ============================================================ + // Library order (List) + // ============================================================ + + group('Library order', () { + test('default is null', () async { + final s = await StorageService.getInstance(); + expect(s.getLibraryOrder(), isNull); + }); + + test('round-trip preserves order', () async { + final s = await StorageService.getInstance(); + await s.saveLibraryOrder(['c', 'a', 'b']); + expect(s.getLibraryOrder(), ['c', 'a', 'b']); + }); + + test('legacy unscoped value migrates into scoped key when user UUID is set', () async { + final s = await StorageService.getInstance(); + + // Write a legacy (unscoped) library order, mimicking pre-multi-user data. + await s.prefs.setString('library_order', json.encode(['x', 'y'])); + + // Set a current user UUID so reads/writes become scoped. + await s.saveCurrentUserUUID('user-1'); + + final read = s.getLibraryOrder(); + expect(read, ['x', 'y']); + + // Migration should have copied the legacy value under the scoped key. + final scopedRaw = s.prefs.getString('user_user-1_library_order'); + expect(scopedRaw, json.encode(['x', 'y'])); + }); + + test('per-user scoping isolates orders', () async { + final s = await StorageService.getInstance(); + + await s.saveCurrentUserUUID('user-1'); + await s.saveLibraryOrder(['u1-a', 'u1-b']); + + await s.saveCurrentUserUUID('user-2'); + expect(s.getLibraryOrder(), isNull); + await s.saveLibraryOrder(['u2-a']); + + // Switch back — user-1 sees their own list. + await s.saveCurrentUserUUID('user-1'); + expect(s.getLibraryOrder(), ['u1-a', 'u1-b']); + + await s.saveCurrentUserUUID('user-2'); + expect(s.getLibraryOrder(), ['u2-a']); + }); + }); + + // ============================================================ + // Library filters / sort / grouping / tab + // ============================================================ + + group('Library filters / sort / grouping / tab', () { + test('global filters round-trip', () async { + final s = await StorageService.getInstance(); + expect(s.getLibraryFilters(), isEmpty); + await s.saveLibraryFilters({'genre': 'sci-fi', 'year': '2024'}); + expect(s.getLibraryFilters(), {'genre': 'sci-fi', 'year': '2024'}); + }); + + test('per-section filters fall back to global when missing', () async { + final s = await StorageService.getInstance(); + await s.saveLibraryFilters({'global': 'true'}); + expect(s.getLibraryFilters(sectionId: 'sec-1'), {'global': 'true'}); + + await s.saveLibraryFilters({'genre': 'horror'}, sectionId: 'sec-1'); + expect(s.getLibraryFilters(sectionId: 'sec-1'), {'genre': 'horror'}); + expect(s.getLibraryFilters(), {'global': 'true'}); + }); + + test('library sort round-trips with descending flag', () async { + final s = await StorageService.getInstance(); + await s.saveLibrarySort('sec-1', 'titleSort', descending: true); + expect(s.getLibrarySort('sec-1'), {'key': 'titleSort', 'descending': true}); + + await s.saveLibrarySort('sec-1', 'addedAt'); + expect(s.getLibrarySort('sec-1'), {'key': 'addedAt', 'descending': false}); + }); + + test('library sort: legacy plain-string value migrates to map shape', () async { + final s = await StorageService.getInstance(); + // Pre-existing legacy plain string under the unscoped key. + await s.prefs.setString('library_sort_sec-1', 'titleSort'); + // _readJsonMap with legacyStringOk=true should normalize to the map shape. + final result = s.getLibrarySort('sec-1'); + expect(result, {'key': 'titleSort', 'descending': false}); + }); + + test('library grouping round-trips', () async { + final s = await StorageService.getInstance(); + expect(s.getLibraryGrouping('sec-1'), isNull); + await s.saveLibraryGrouping('sec-1', 'shows'); + expect(s.getLibraryGrouping('sec-1'), 'shows'); + }); + + test('library tab round-trips', () async { + final s = await StorageService.getInstance(); + expect(s.getLibraryTab('sec-1'), isNull); + await s.saveLibraryTab('sec-1', 'recommended'); + expect(s.getLibraryTab('sec-1'), 'recommended'); + }); + + test('saveSelectedLibraryKey + getSelectedLibraryKey round-trip', () async { + final s = await StorageService.getInstance(); + expect(s.getSelectedLibraryKey(), isNull); + await s.saveSelectedLibraryKey('lib-key-42'); + expect(s.getSelectedLibraryKey(), 'lib-key-42'); + }); + }); + + // ============================================================ + // User profile / UUID + // ============================================================ + + group('User profile & UUID', () { + test('saveUserProfile + getUserProfile round-trip preserves nested data', () async { + final s = await StorageService.getInstance(); + expect(s.getUserProfile(), isNull); + final profile = {'id': 1, 'username': 'edde', 'email': 'e@example.test'}; + await s.saveUserProfile(profile); + expect(s.getUserProfile(), profile); + }); + + test('saveCurrentUserUUID + clearCurrentUserUUID', () async { + final s = await StorageService.getInstance(); + await s.saveCurrentUserUUID('u-1'); + expect(s.getCurrentUserUUID(), 'u-1'); + await s.clearCurrentUserUUID(); + expect(s.getCurrentUserUUID(), isNull); + }); + }); + + // ============================================================ + // Home users cache (TTL) + // ============================================================ + + group('Home users cache', () { + test('saved cache is readable while non-expired', () async { + final s = await StorageService.getInstance(); + await s.saveHomeUsersCache({'users': []}); + expect(s.getHomeUsersCache(), {'users': []}); + }); + + test('expired cache returns null and self-clears', () async { + final s = await StorageService.getInstance(); + await s.saveHomeUsersCache({'users': []}); + // Force-expire the cache by writing a past timestamp under the expiry key. + await s.prefs.setInt('home_users_cache_expiry', DateTime.now().millisecondsSinceEpoch - 1000); + expect(s.getHomeUsersCache(), isNull); + // After self-clear, both keys are gone. + expect(s.prefs.getString('home_users_cache'), isNull); + expect(s.prefs.getInt('home_users_cache_expiry'), isNull); + }); + + test('clearHomeUsersCache removes both data and expiry', () async { + final s = await StorageService.getInstance(); + await s.saveHomeUsersCache({'users': []}); + await s.clearHomeUsersCache(); + expect(s.getHomeUsersCache(), isNull); + expect(s.prefs.getString('home_users_cache'), isNull); + expect(s.prefs.getInt('home_users_cache_expiry'), isNull); + }); + }); + + // ============================================================ + // Episode count persistence (prefix-based) + // ============================================================ + + group('Episode counts', () { + test('per-key round-trip', () async { + final s = await StorageService.getInstance(); + await s.saveTotalEpisodeCount('srv:show-1', 12); + await s.saveTotalEpisodeCount('srv:show-2', 24); + expect(s.getTotalEpisodeCount('srv:show-1'), 12); + expect(s.getTotalEpisodeCount('srv:show-2'), 24); + expect(s.getTotalEpisodeCount('srv:missing'), isNull); + }); + + test('loadAllEpisodeCounts returns every persisted entry', () async { + final s = await StorageService.getInstance(); + await s.saveTotalEpisodeCount('srv:s1', 1); + await s.saveTotalEpisodeCount('srv:s2', 2); + // Unrelated keys must not bleed in. + await s.savePlexToken('tok'); + + final counts = s.loadAllEpisodeCounts(); + expect(counts, {'srv:s1': 1, 'srv:s2': 2}); + }); + + test('removeEpisodeCount deletes only the targeted entry', () async { + final s = await StorageService.getInstance(); + await s.saveTotalEpisodeCount('srv:s1', 1); + await s.saveTotalEpisodeCount('srv:s2', 2); + await s.removeEpisodeCount('srv:s1'); + expect(s.getTotalEpisodeCount('srv:s1'), isNull); + expect(s.getTotalEpisodeCount('srv:s2'), 2); + }); + }); + + // ============================================================ + // clearCredentials + // ============================================================ + + group('clearCredentials', () { + test('removes credential keys, plex token, and multi-server data', () async { + final s = await StorageService.getInstance(); + + await s.savePlexToken('tok-x'); + await s.saveClientIdentifier('client-x'); + await s.saveUserProfile({'id': 99}); + await s.saveHomeUsersCache({'users': []}); + await s.saveServersListJson('[{"x":1}]'); + await s.saveServerOrder(['a']); + await s.saveServerEndpoint('a', 'http://foo.test'); + + // Library prefs and unrelated counters: write WITHOUT a current-user UUID + // so they land on the legacy unscoped key. The credentials clear path + // wipes current_user_uuid; we want to confirm library data is untouched. + await s.saveLibraryOrder(['lib-1']); + await s.saveTotalEpisodeCount('srv:s1', 7); + + // Now set a user UUID — clearCredentials should remove this. + await s.saveCurrentUserUUID('u-x'); + + await s.clearCredentials(); + + // Credential-bucket keys all gone. + expect(s.getPlexToken(), isNull); + expect(s.getClientIdentifier(), isNull); + expect(s.getCurrentUserUUID(), isNull); + expect(s.getUserProfile(), isNull); + + // Multi-server data wiped. + expect(s.getServersListJson(), isNull); + expect(s.getServerOrder(), isNull); + expect(s.getServerEndpoint('a'), isNull); + + // Library prefs and unrelated state untouched (user UUID is gone, so + // the scoped read falls through to the same legacy key it was written to). + expect(s.getLibraryOrder(), ['lib-1']); + expect(s.getTotalEpisodeCount('srv:s1'), 7); + }); + }); + + // ============================================================ + // clearLibraryPreferences (user-scoped) + // ============================================================ + + group('clearLibraryPreferences', () { + test('clears scoped library keys for current user only', () async { + final s = await StorageService.getInstance(); + + // user-1's library prefs + await s.saveCurrentUserUUID('user-1'); + await s.saveLibraryOrder(['u1-a', 'u1-b']); + await s.saveSelectedLibraryKey('u1-key'); + await s.saveLibraryFilters({'genre': 'horror'}, sectionId: 'sec-1'); + await s.saveLibrarySort('sec-1', 'titleSort', descending: true); + await s.saveLibraryGrouping('sec-1', 'shows'); + await s.saveLibraryTab('sec-1', 'tabA'); + await s.saveHiddenLibraries({'h-1'}); + + // user-2's library prefs (must not be touched by clearing user-1) + await s.saveCurrentUserUUID('user-2'); + await s.saveLibraryOrder(['u2-a']); + await s.saveSelectedLibraryKey('u2-key'); + + // Clear user-2 first to ensure user-2 keys are gone, then verify user-1's intact. + await s.clearLibraryPreferences(); + expect(s.getLibraryOrder(), isNull); + expect(s.getSelectedLibraryKey(), isNull); + + await s.saveCurrentUserUUID('user-1'); + expect(s.getLibraryOrder(), ['u1-a', 'u1-b']); + expect(s.getSelectedLibraryKey(), 'u1-key'); + expect(s.getLibraryFilters(sectionId: 'sec-1'), {'genre': 'horror'}); + expect(s.getLibrarySort('sec-1'), {'key': 'titleSort', 'descending': true}); + expect(s.getLibraryGrouping('sec-1'), 'shows'); + expect(s.getLibraryTab('sec-1'), 'tabA'); + expect(s.getHiddenLibraries(), {'h-1'}); + + // Now clear user-1 and confirm everything for that user goes away. + await s.clearLibraryPreferences(); + expect(s.getLibraryOrder(), isNull); + expect(s.getSelectedLibraryKey(), isNull); + expect(s.getLibraryFilters(sectionId: 'sec-1'), isEmpty); + expect(s.getLibrarySort('sec-1'), isNull); + expect(s.getLibraryGrouping('sec-1'), isNull); + expect(s.getLibraryTab('sec-1'), isNull); + expect(s.getHiddenLibraries(), isEmpty); + }); + }); + + // ============================================================ + // clearUserData = clearCredentials + clearLibraryPreferences + // ============================================================ + + group('clearUserData', () { + test('combines credentials and library-preferences clear', () async { + final s = await StorageService.getInstance(); + + await s.savePlexToken('tok'); + await s.saveCurrentUserUUID('user-1'); + await s.saveLibraryOrder(['lib-a']); + await s.saveHiddenLibraries({'h-1'}); + + await s.clearUserData(); + + expect(s.getPlexToken(), isNull); + // current_user_uuid is part of the credentials bucket; clearing it + // means the scoped-key prefix flips to empty and reads return null. + expect(s.getCurrentUserUUID(), isNull); + expect(s.getLibraryOrder(), isNull); + expect(s.getHiddenLibraries(), isEmpty); + }); + }); +}