test: provider/mixin/service tests + visibleForTesting reset hooks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user