test: provider/mixin/service tests + visibleForTesting reset hooks

This commit is contained in:
edde746
2026-04-25 13:15:01 +02:00
parent 7f8c066062
commit f049c65623
14 changed files with 2562 additions and 2 deletions
@@ -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;
+9
View File
@@ -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<String, String> defaultKeyboardShortcuts() => _defaultKeyboardShortcuts();
static Map<String, HotKey> defaultKeyboardHotkeys() => _defaultKeyboardHotkeys();
+2 -2
View File
@@ -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"
+2
View File
@@ -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:
@@ -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);
});
});
}
+292
View File
@@ -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<String> parentChain;
@override
String get globalKey => buildGlobalKey(serverId, ratingKey);
}
class _FakeNotifier extends BaseNotifier<_FakeEvent> {}
/// Helper to drain the broadcast microtasks before reading received events.
Future<void> _settle() => Future<void>.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: () => <String>{},
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 = <String>{'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<StreamSubscription<_FakeEvent>>());
sub.cancel();
});
});
}
@@ -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);
});
});
}
+210
View File
@@ -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<Refreshable>());
// 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<Refreshable>()));
expect(state, isNot(isA<FullRefreshable>()));
expect(state, isNot(isA<FocusableTab>()));
expect(state, isNot(isA<SearchInputFocusable>()));
expect(state, isNot(isA<LibraryLoadable>()));
});
});
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<FocusableTab>());
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<Refreshable>());
expect(state, isA<FullRefreshable>());
expect(state, isA<FocusableTab>());
expect(state, isA<SearchInputFocusable>());
expect(state, isA<LibraryLoadable>());
});
});
}
@@ -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();
});
});
}
@@ -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<PlexMetadata>? 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);
});
});
}
@@ -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<PlexHomeUser>? 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();
});
});
}
@@ -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<String?> getApplicationDocumentsPath() async => _ensure(_docs);
@override
Future<String?> getApplicationSupportPath() async => _ensure(_support);
@override
Future<String?> getApplicationCachePath() async => _ensure(_cache);
@override
Future<String?> 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 <appSupport>/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,
);
}
+276
View File
@@ -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<PlexConnection>? 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<void> 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<dynamic>;
expect(decoded, hasLength(1));
final parsed = PlexServer.fromJson(decoded.first as Map<String, dynamic>);
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']));
});
});
}
+512
View File
@@ -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<String> 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<String>)
// ============================================================
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);
});
});
}