import 'dart:async'; import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary( id: key, backend: MediaBackend.plex, title: title, kind: MediaKind.fromString(type), serverId: serverId, ); MediaLibrary _serverLib(ServerId serverId, String id, String title) => MediaLibrary(id: id, backend: MediaBackend.plex, title: title, kind: MediaKind.movie, serverId: serverId); /// Minimal [MediaServerClient] returning canned libraries; only the surface the /// aggregation service touches is implemented. An optional [gate] lets a test /// hold `fetchLibraries` open to exercise the mid-load race; setting [error] /// makes `fetchLibraries` throw, simulating a (possibly transient) failure. class _FakeClient implements MediaServerClient { _FakeClient({required this.serverId, this.libraries = const [], this.gate, this.errorForCall}); @override final ServerId serverId; @override final String serverName = 'Server'; final List libraries; final Future? gate; final Object? Function(int call)? errorForCall; /// When non-null, [fetchLibraries] throws this instead of returning. Mutable /// so a test can fail a fetch once and then let it recover. Object? error; int fetchLibrariesCalls = 0; @override Future> fetchLibraries() async { fetchLibrariesCalls++; final pending = gate; if (pending != null) await pending; final fetchError = errorForCall?.call(fetchLibrariesCalls) ?? error; if (fetchError != null) throw fetchError; return libraries; } @override void close() {} @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } void main() { setUp(resetSharedPreferencesForTest); group('LibrariesProvider', () { test('starts with initial empty state', () { final p = LibrariesProvider(); expect(p.libraries, isEmpty); expect(p.hasLibraries, isFalse); expect(p.isLoading, isFalse); expect(p.hasLoaded, isFalse); expect(p.loadState, LibrariesLoadState.initial); expect(p.errorMessage, isNull); p.dispose(); }); test('loadLibraries before initialize is a no-op', () async { final p = LibrariesProvider(); var notified = 0; p.addListener(() => notified++); await p.loadLibraries(); // Without a DataAggregationService the load short-circuits with no // state transition and no listener notification. expect(p.loadState, LibrariesLoadState.initial); expect(p.libraries, isEmpty); expect(notified, 0); p.dispose(); }); test('refresh before initialize is a no-op', () async { final p = LibrariesProvider(); var notified = 0; p.addListener(() => notified++); await p.refresh(); expect(p.loadState, LibrariesLoadState.initial); expect(notified, 0); p.dispose(); }); test('updateLibraryOrder updates list, notifies, and persists order', () async { final p = LibrariesProvider(); var notified = 0; p.addListener(() => notified++); final libs = [ _lib('1', serverId: ServerId('srv'), title: 'A'), _lib('2', serverId: ServerId('srv'), title: 'B'), _lib('3', serverId: ServerId('srv'), title: 'C'), ]; await p.updateLibraryOrder(libs); expect(p.libraries.length, 3); expect(p.libraries.map((l) => l.title), ['A', 'B', 'C']); expect(notified, 1); // Persisted to storage as the list of globalKeys. final storage = await StorageService.getInstance(); expect(storage.getLibraryOrder(), equals(libs.map((l) => l.globalKey).toList())); p.dispose(); }); test('libraries getter returns an unmodifiable list', () async { final p = LibrariesProvider(); await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]); expect(() => p.libraries.add(_lib('mutated')), throwsUnsupportedError); p.dispose(); }); test('clear resets state to initial and notifies', () async { final p = LibrariesProvider(); await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv')), _lib('2', serverId: ServerId('srv'))]); expect(p.libraries, hasLength(2)); var notified = 0; p.addListener(() => notified++); p.clear(); expect(p.libraries, isEmpty); expect(p.hasLibraries, isFalse); expect(p.loadState, LibrariesLoadState.initial); expect(p.errorMessage, isNull); expect(notified, 1); p.dispose(); }); test('mutating methods after dispose are no-ops', () async { final p = LibrariesProvider(); p.dispose(); p.clear(); await p.updateLibraryOrder([_lib('1', serverId: ServerId('srv'))]); expect(p.libraries, isEmpty); final storage = await StorageService.getInstance(); expect(storage.getLibraryOrder(), isNull); }); }); group('LibrariesProvider.syncToOnlineServers', () { test('loads when a server first comes online', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); expect(p.hasLoaded, isTrue); expect(p.libraries.map((l) => l.title), ['Movies A']); expect(clientA.fetchLibrariesCalls, 1); p.dispose(); manager.dispose(); }); test('does not reload when the online set is unchanged', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); await p.syncToOnlineServers({'A'}); // already covered → no-op expect(clientA.fetchLibrariesCalls, 1); p.dispose(); manager.dispose(); }); test('delta-loads only a server that connects after the first load', () async { // A server binding in a later wave (borrowed connection, or a slow // server reconnecting after timing out) must surface without a profile // re-switch — and without refetching the servers already loaded. final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); expect(p.libraries.map((l) => l.title), ['Movies A']); final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientB); await p.syncToOnlineServers({'A', 'B'}); expect(p.libraries.map((l) => l.title), containsAll(['Movies A', 'Shows B'])); expect(clientA.fetchLibrariesCalls, 1, reason: 'already-loaded server is not refetched'); expect(clientB.fetchLibrariesCalls, 1); p.dispose(); manager.dispose(); }); test('a background reload over existing data never surfaces a loading state', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); expect(p.hasLoaded, isTrue); // A server connecting later must not flip the provider back to a loading // state — screens render `isLoading` as a full-screen spinner, so a // background reload would blank content the user is already viewing. final sawLoading = []; p.addListener(() => sawLoading.add(p.isLoading)); final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientB); await p.syncToOnlineServers({'A', 'B'}); expect(sawLoading, isNot(contains(true))); expect(p.libraries.map((l) => l.title), containsAll(['Movies A', 'Shows B'])); p.dispose(); manager.dispose(); }); test('a server whose fetch fails is retried on the next sync, not cached as loaded', () async { // Regression: getMediaLibrariesFromAllServers swallows a per-server fetch // failure and returns no libraries for it — identical to a genuinely empty // server. Keying loaded-state on fetch *success* keeps a transiently // failed server out of _loadedServerIds so it reloads instead of staying // missing until a profile re-switch/restart. final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]) ..error = Exception('transient'); manager.debugRegisterClientForTesting(clientA); manager.debugRegisterClientForTesting(clientB); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A', 'B'}); // A loaded; B's fetch failed, so it is absent and must not be recorded. expect(p.libraries.map((l) => l.title), ['Movies A']); // B recovers. The same online set must now reload it rather than treating // B as already covered. clientB.error = null; await p.syncToOnlineServers({'A', 'B'}); expect(p.libraries.map((l) => l.title), containsAll(['Movies A', 'Shows B'])); p.dispose(); manager.dispose(); }); test('does not reload when the online set shrinks', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientA); manager.debugRegisterClientForTesting(clientB); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A', 'B'}); expect(clientA.fetchLibrariesCalls, 1); // A drops; the visible online set is now a subset of what we loaded. await p.syncToOnlineServers({'B'}); expect(clientA.fetchLibrariesCalls, 1); expect(clientB.fetchLibrariesCalls, 1); p.dispose(); manager.dispose(); }); test('a zero-library server is marked loaded and does not retrigger', () async { final manager = MultiServerManager(); final clientC = _FakeClient(serverId: ServerId('C'), libraries: const []); manager.debugRegisterClientForTesting(clientC); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'C'}); expect(p.hasLoaded, isTrue); expect(p.libraries, isEmpty); expect(clientC.fetchLibrariesCalls, 1); // Tracking the requested set (not deriving from loaded libraries) is what // stops a zero-library server from looking "never loaded" and reloading // on every status emission. await p.syncToOnlineServers({'C'}); expect(clientC.fetchLibrariesCalls, 1); p.dispose(); manager.dispose(); }); test('a server appearing mid-load is still picked up', () async { final manager = MultiServerManager(); final gate = Completer(); final clientA = _FakeClient( serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')], gate: gate.future, ); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); // First load starts and suspends on A's gated fetch. final inFlight = p.syncToOnlineServers({'A'}); // B comes online before the first load completes. final clientB = _FakeClient(serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')]); manager.debugRegisterClientForTesting(clientB); unawaited(p.syncToOnlineServers({'A', 'B'})); // queued behind the in-flight pass gate.complete(); await inFlight; // resolves after the replayed pass covering {A, B} expect(p.libraries.map((l) => l.title), containsAll(['Movies A', 'Shows B'])); expect(clientA.fetchLibrariesCalls, 2, reason: 'a second pass runs for the larger set'); p.dispose(); manager.dispose(); }); test('online-server deltas arriving mid-pass are unioned into one trailing pass', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); final gate = Completer(); final clientB = _FakeClient( serverId: ServerId('B'), libraries: [_serverLib(ServerId('B'), '1', 'Shows B')], gate: gate.future, ); manager.debugRegisterClientForTesting(clientB); final firstDelta = p.syncToOnlineServers({'A', 'B'}); expect(clientB.fetchLibrariesCalls, 1); final clientC = _FakeClient(serverId: ServerId('C'), libraries: [_serverLib(ServerId('C'), '1', 'Movies C')]); manager.debugRegisterClientForTesting(clientC); final trailingDelta = p.syncToOnlineServers({'A', 'B', 'C'}); gate.complete(); await Future.wait([firstDelta, trailingDelta]); expect(clientA.fetchLibrariesCalls, 1); expect(clientB.fetchLibrariesCalls, 1, reason: 'the trailing delta drops ids committed by the active pass'); expect(clientC.fetchLibrariesCalls, 1); expect(p.libraries.map((library) => library.title), containsAll(['Movies A', 'Shows B', 'Movies C'])); p.dispose(); manager.dispose(); }); test('a coalesced call gets its trailing pass after the in-flight pass fails', () async { final manager = MultiServerManager(); final gate = Completer(); final clientA = _FakeClient( serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')], gate: gate.future, errorForCall: (call) => call == 1 ? MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'first pass cancelled') : null, ); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); final first = p.loadLibraries(); final coalesced = p.loadLibraries(); expect(clientA.fetchLibrariesCalls, 1); gate.complete(); await Future.wait([first, coalesced]); expect(clientA.fetchLibrariesCalls, 2); expect(p.hasLoaded, isTrue); expect(p.libraries.map((library) => library.title), ['Movies A']); p.dispose(); manager.dispose(); }); test('dispose during an in-flight coalesced load prevents trailing work and commits', () async { final manager = MultiServerManager(); final gate = Completer(); final clientA = _FakeClient( serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')], gate: gate.future, ); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); var notifications = 0; p.addListener(() => notifications++); final first = p.loadLibraries(); final coalesced = p.loadLibraries(); expect(clientA.fetchLibrariesCalls, 1); expect(notifications, 1, reason: 'the initial loading state was published before disposal'); p.dispose(); gate.complete(); await Future.wait([first, coalesced]); expect(clientA.fetchLibrariesCalls, 1, reason: 'the queued trailing pass was discarded'); expect(p.libraries, isEmpty, reason: 'the completed fetch was not committed after disposal'); expect(notifications, 1); await p.loadLibraries(); await p.syncToOnlineServers({'A'}); expect(clientA.fetchLibrariesCalls, 1, reason: 'post-dispose entry points are no-ops'); manager.dispose(); }); test('clear() resets tracking so the next sync reloads', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({'A'}); expect(clientA.fetchLibrariesCalls, 1); p.clear(); expect(p.hasLoaded, isFalse); await p.syncToOnlineServers({'A'}); expect(clientA.fetchLibrariesCalls, 2); p.dispose(); manager.dispose(); }); test('a first load disrupted by cancellations stays loading instead of flashing empty', () async { // The sign-in empty-flash regression: a rebind tore the client down // mid-fetch, the aborted pass used to commit loaded-empty, and the // sidebar flashed "no libraries found" until the follow-up load landed. final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]) ..error = MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.loadLibraries(); expect(p.isLoading, isTrue); expect(p.hasLoaded, isFalse); expect(p.errorMessage, isNull); // The guaranteed follow-up load (binding-settle prime / next status // emission) lands the real list. clientA.error = null; await p.loadLibraries(); expect(p.hasLoaded, isTrue); expect(p.libraries.map((l) => l.title), ['Movies A']); p.dispose(); manager.dispose(); }); test('a zero-success first load during profile binding stays loading', () async { // The timeout-during-bind window: every fetch failed while the binder // was still wiring servers, with no cancellation marker. final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]) ..error = Exception('probe timed out'); manager.debugRegisterClientForTesting(clientA); var binding = true; final p = LibrariesProvider(isProfileBinding: () => binding)..initialize(DataAggregationService(manager)); await p.loadLibraries(); expect(p.isLoading, isTrue); expect(p.hasLoaded, isFalse); binding = false; clientA.error = null; await p.loadLibraries(); expect(p.hasLoaded, isTrue); expect(p.libraries.map((l) => l.title), ['Movies A']); p.dispose(); manager.dispose(); }); test('a settled zero-success first load still commits loaded-empty', () async { // Locks the no-eternal-spinner constraint: a genuinely dead server // outside any disruption window keeps today's empty state. final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'))..error = Exception('connection refused'); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.loadLibraries(); expect(p.hasLoaded, isTrue); expect(p.libraries, isEmpty); expect(p.errorMessage, isNull); p.dispose(); manager.dispose(); }); test('a totally failed silent refresh keeps the last good list', () async { // Regression (pre-existing wipe bug): a reload-in-place where every // server fails without throwing used to replace the list with [] — // blanking the sidebar on a transient outage. final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.loadLibraries(); expect(p.libraries.map((l) => l.title), ['Movies A']); clientA.error = Exception('offline'); await p.loadLibraries(); expect(p.hasLoaded, isTrue); expect(p.libraries.map((l) => l.title), ['Movies A']); // The kept list does not count as covering the failed server — the // next sync refetches it. clientA.error = null; final callsBefore = clientA.fetchLibrariesCalls; await p.syncToOnlineServers({'A'}); expect(clientA.fetchLibrariesCalls, callsBefore + 1); p.dispose(); manager.dispose(); }); test('online-servers listener is removed on dispose', () { final manager = MultiServerManager(); final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); final before = multiServer.onlineServersListenerCount; final scoped = LibrariesProvider(multiServer: multiServer); expect(multiServer.onlineServersListenerCount, before + 1); scoped.dispose(); expect(multiServer.onlineServersListenerCount, before); multiServer.dispose(); manager.dispose(); }); test('is a no-op for an empty set or before initialize', () async { final manager = MultiServerManager(); final clientA = _FakeClient(serverId: ServerId('A'), libraries: [_serverLib(ServerId('A'), '1', 'Movies A')]); manager.debugRegisterClientForTesting(clientA); // Empty set on an initialized provider. final p = LibrariesProvider()..initialize(DataAggregationService(manager)); await p.syncToOnlineServers({}); expect(p.loadState, LibrariesLoadState.initial); expect(clientA.fetchLibrariesCalls, 0); p.dispose(); // Non-empty set on an uninitialized provider. final p2 = LibrariesProvider(); var notified = 0; p2.addListener(() => notified++); await p2.syncToOnlineServers({'A'}); expect(p2.loadState, LibrariesLoadState.initial); expect(notified, 0); p2.dispose(); manager.dispose(); }); }); }