import 'dart:async'; import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import '../test_helpers/prefs.dart'; JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( id: 'jf-machine/$userId', baseUrl: 'https://jf.example.com', serverName: 'Shared JF', serverMachineId: 'jf-machine', userId: userId, userName: userId, accessToken: 'token-$userId', deviceId: 'device', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); JellyfinClient _jellyfinClient(String userId) => JellyfinClient.forTesting( connection: _jellyfinConnection(userId), httpClient: MockClient((_) async => http.Response('{}', 200)), ); // NOTE on coverage scope: // [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer` // all instantiate a real `PlexClient` via `findBestWorkingConnection`, which // performs live HTTP calls to a Plex Media Server. The manager does NOT expose // a fake `PlexClient` factory, so per the task brief we don't fake the network // here. // // The tests below cover the orchestration logic that DOESN'T require a network: // - construction & initial state // - `removeServer` (pure local-map mutation) // - `updateServerStatus` + status-stream emissions // - `disconnectAll` / `dispose` lifecycle (no connectivity sub started, so // this verifies the no-op path for the subscription cancel) // // What is NOT covered here (would need a fake PlexClient factory): // - `addServer` success path // - `connectToAllServers` outcome map // - `checkServerHealth` health-probe sweep // - `_reoptimizeServer` endpoint promotion // - `_onServerEndpointsExhausted` debounce → reconnect // - `startNetworkMonitoring` connectivity-listener path void main() { setUp(resetSharedPreferencesForTest); // ============================================================ // Initial state // ============================================================ group('initial state', () { test('a freshly constructed manager has no servers, clients, or status', () { final m = MultiServerManager(); addTearDown(m.dispose); expect(m.serverIds, isEmpty); expect(m.onlineServerIds, isEmpty); expect(m.offlineServerIds, isEmpty); expect(m.plexServers, isEmpty); expect(m.onlineClients, isEmpty); }); test('getClient/getPlexServer return null for unknown ids', () { final m = MultiServerManager(); addTearDown(m.dispose); expect(m.getClient(ServerId('nope')), isNull); expect(m.getPlexServer(ServerId('nope')), isNull); expect(m.isServerOnline(ServerId('nope')), isFalse); }); test('plexServers map is unmodifiable', () { final m = MultiServerManager(); addTearDown(m.dispose); // Map.unmodifiable rejects every mutating operation — clear() is the // simplest no-arg one to exercise the wrapper. expect(() => m.plexServers.clear(), throwsUnsupportedError); }); }); // ============================================================ // updateServerStatus + status stream // ============================================================ group('updateServerStatus + statusStream', () { test('emits a snapshot when status flips for a tracked server', () async { final m = MultiServerManager(); addTearDown(m.dispose); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); // Pre-seed status (mirrors what addServer would do post-connect). m.updateServerStatus(ServerId('srv-1'), true); m.updateServerStatus(ServerId('srv-2'), false); m.updateServerStatus(ServerId('srv-1'), false); // change // Let the broadcast stream events drain. await Future.delayed(Duration.zero); expect(emitted, hasLength(3)); expect(emitted[0], {'srv-1': true}); expect(emitted[1], {'srv-1': true, 'srv-2': false}); expect(emitted[2], {'srv-1': false, 'srv-2': false}); }); test('repeated identical status is debounced (no extra emission)', () async { final m = MultiServerManager(); addTearDown(m.dispose); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); m.updateServerStatus(ServerId('srv-1'), true); m.updateServerStatus(ServerId('srv-1'), true); // same value: no-op m.updateServerStatus(ServerId('srv-1'), true); await Future.delayed(Duration.zero); expect(emitted, hasLength(1)); expect(emitted.first, {'srv-1': true}); }); test('online/offline server-id getters reflect updateServerStatus', () { final m = MultiServerManager(); addTearDown(m.dispose); m.updateServerStatus(ServerId('a'), true); m.updateServerStatus(ServerId('b'), false); m.updateServerStatus(ServerId('c'), true); expect(m.onlineServerIds.toSet(), {'a', 'c'}); expect(m.offlineServerIds.toSet(), {'b'}); expect(m.isServerOnline(ServerId('a')), isTrue); expect(m.isServerOnline(ServerId('b')), isFalse); }); }); group('refreshTokensForProfile', () { test('successful in-place Plex token refresh clears auth-error state', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); addTearDown(db.close); final m = MultiServerManager(); addTearDown(m.dispose); final client = PlexClient.forTesting( config: PlexConfig( baseUrl: 'https://plex.example', token: 'old-token', clientIdentifier: 'client-id', product: 'Plezy', version: '1.0.0', ), serverId: ServerId('server-1'), serverName: 'Plex', httpClient: MockClient((_) async => http.Response('{}', 200)), ); m.debugRegisterClientForTesting(client, online: true); m.debugMarkAuthErrorForTesting(ServerId('server-1')); final bound = await m.refreshTokensForProfile( PlexAccountConnection( id: 'account-1', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Account', servers: [ PlexServer( name: 'Plex', clientIdentifier: 'server-1', accessToken: 'new-token', connections: const [], owned: true, ), ], createdAt: DateTime.fromMillisecondsSinceEpoch(0), ), ); expect(bound, {'server-1'}); expect(m.authErrorServerIds, isNot(contains('server-1'))); expect(client.config.token, 'new-token'); }); }); group('Jellyfin connection updates', () { test('persists refreshed admin status discovered during health checks', () async { final persisted = []; final persistStarted = Completer(); final allowPersist = Completer(); final client = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient((request) async { expect(request.url.path, '/Users/Me'); return http.Response( '{"Policy":{"IsAdministrator":true}}', 200, headers: {'content-type': 'application/json'}, ); }), ); addTearDown(client.close); final m = MultiServerManager() ..onJellyfinConnectionUpdated = (connection) async { persistStarted.complete(); await allowPersist.future; persisted.add(connection); }; addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(client); final healthFuture = m.checkServerHealth(); await persistStarted.future; expect(persisted, isEmpty); allowPersist.complete(); await healthFuture; expect(persisted, hasLength(1)); expect(persisted.single.isAdministrator, isTrue); }); test('health remains online when persisting refreshed admin status fails', () async { final client = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient( (_) async => http.Response('{"Policy":{"IsAdministrator":true}}', 200, headers: {'content-type': 'application/json'}), ), ); addTearDown(client.close); final m = MultiServerManager() ..onJellyfinConnectionUpdated = (_) async { throw Exception('disk full'); }; addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(client); await m.checkServerHealth(); expect(m.isServerOnline(ServerId('jf-machine')), isTrue); expect(m.isOwnerOrAdmin(ServerId('jf-machine')), isTrue); }); test('ignores stale admin-status persistence from a replaced Jellyfin client', () async { final persisted = []; final requestStarted = Completer(); final allowResponse = Completer(); final oldClient = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient((_) async { requestStarted.complete(); await allowResponse.future; return http.Response( '{"Policy":{"IsAdministrator":true}}', 200, headers: {'content-type': 'application/json'}, ); }), ); final newClient = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a').copyWith(accessToken: 'new-token'), httpClient: MockClient((_) async => http.Response('{}', 200)), ); addTearDown(oldClient.close); final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(oldClient); final healthFuture = m.checkServerHealth(); await requestStarted.future; m.debugRegisterJellyfinClientForTesting(newClient); allowResponse.complete(); await healthFuture; expect(persisted, isEmpty); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), same(newClient)); }); test('ignores stale health status when active Jellyfin user changes mid-check', () async { final requestStarted = Completer(); final allowResponse = Completer(); final userA = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient((_) async { requestStarted.complete(); await allowResponse.future; return http.Response('', 403); }), ); final userB = _jellyfinClient('user-b'); final m = MultiServerManager(); addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(userA); final healthFuture = m.checkServerHealth(); await requestStarted.future; m.debugRegisterJellyfinClientForTesting(userB, online: true); allowResponse.complete(); await healthFuture; expect(m.getClient(ServerId('jf-machine')), same(userB)); expect(m.isServerOnline(ServerId('jf-machine')), isTrue); expect(m.authErrorServerIds, isNot(contains('jf-machine'))); }); }); // ============================================================ // addJellyfinConnection reuse // ============================================================ group('addJellyfinConnection reuse', () { // The reuse branch is what keeps a passive rebind (re-adding the same // persisted connection) from tearing down a live client and aborting its // in-flight requests. The identity assertions are load-bearing: any // recreation implies the prior client was closed. test('re-adding an identical connection reuses the live client', () async { var probes = 0; final client = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient((request) async { probes++; expect(request.url.path, '/Users/Me'); return http.Response('{}', 200, headers: {'content-type': 'application/json'}); }), ); addTearDown(client.close); final m = MultiServerManager(); addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(client); final healthy = await m.addJellyfinConnection(_jellyfinConnection('user-a')); expect(healthy, isTrue); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), same(client)); expect(m.getClient(ServerId('jf-machine')), same(client)); // One fresh health probe on the existing client; no recreation. expect(probes, 1); }); test('reuse rebinds an inactive compound client without closing the active one', () async { JellyfinClient clientFor(String userId) => JellyfinClient.forTesting( connection: _jellyfinConnection(userId), httpClient: MockClient((_) async => http.Response('{}', 200, headers: {'content-type': 'application/json'})), ); final userA = clientFor('user-a'); final userB = clientFor('user-b'); addTearDown(userA.close); addTearDown(userB.close); final m = MultiServerManager(); addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(userA); m.debugRegisterJellyfinClientForTesting(userB); // takes the machine slot await m.addJellyfinConnection(_jellyfinConnection('user-a')); expect(m.getClient(ServerId('jf-machine')), same(userA)); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), same(userA)); // The other user's client stays registered for a future switch back. expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), same(userB)); }); }); group('canReuseJellyfinClient', () { // A false verdict routes addJellyfinConnection to the pre-existing // replace path (covered by 'ignores stale admin-status persistence from // a replaced Jellyfin client' above). final base = _jellyfinConnection('user-a'); test('identical connection is reusable', () { expect(MultiServerManager.canReuseJellyfinClient(live: base, incoming: _jellyfinConnection('user-a')), isTrue); }); test('changed access token requires recreation', () { expect( MultiServerManager.canReuseJellyfinClient( live: base, incoming: base.copyWith(accessToken: 'rotated'), ), isFalse, ); }); test('changed device id requires recreation', () { expect( MultiServerManager.canReuseJellyfinClient( live: base, incoming: base.copyWith(deviceId: 'other-device'), ), isFalse, ); }); test('same URL set with a different active URL is reusable', () { // The live client rotates its active endpoint on its own; the add-path // race reorders the candidate list. Neither warrants a teardown. final live = base.copyWith( baseUrl: 'https://a.example.com', baseUrls: ['https://a.example.com', 'https://b.example.com'], ); final incoming = base.copyWith( baseUrl: 'https://b.example.com', baseUrls: ['https://b.example.com', 'https://a.example.com'], ); expect(MultiServerManager.canReuseJellyfinClient(live: live, incoming: incoming), isTrue); }); test('an added or removed URL requires recreation', () { final twoUrls = base.copyWith(baseUrls: ['https://jf.example.com', 'https://alt.example.com']); expect(MultiServerManager.canReuseJellyfinClient(live: twoUrls, incoming: base), isFalse); expect(MultiServerManager.canReuseJellyfinClient(live: base, incoming: twoUrls), isFalse); }); }); // ============================================================ // removeServer // ============================================================ group('removeServer', () { test('removes a tracked server\'s status entry and emits a snapshot', () async { final m = MultiServerManager(); addTearDown(m.dispose); m.updateServerStatus(ServerId('srv-1'), true); m.updateServerStatus(ServerId('srv-2'), true); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); m.removeServer(ServerId('srv-1')); await Future.delayed(Duration.zero); expect(m.serverIds, isNot(contains('srv-1'))); expect(emitted, isNotEmpty); expect(emitted.last, {'srv-2': true}); }); test('removing an unknown id still emits a snapshot (does not throw)', () async { final m = MultiServerManager(); addTearDown(m.dispose); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); m.removeServer(ServerId('never-added')); await Future.delayed(Duration.zero); // Doesn't throw; state stays empty; one snapshot fires. expect(m.serverIds, isEmpty); expect(emitted, hasLength(1)); expect(emitted.first, isEmpty); }); test('removing a Jellyfin machine clears every scoped user client', () { final m = MultiServerManager(); addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-a')); m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-b')); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNotNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNotNull); m.removeServer(ServerId('jf-machine')); expect(m.getClient(ServerId('jf-machine')), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull); }); }); // ============================================================ // disconnectAll // ============================================================ group('disconnectAll', () { test('clears all status and emits an empty snapshot', () async { final m = MultiServerManager(); addTearDown(m.dispose); m.updateServerStatus(ServerId('a'), true); m.updateServerStatus(ServerId('b'), false); final emitted = >[]; final sub = m.statusStream.listen(emitted.add); addTearDown(sub.cancel); m.disconnectAll(); await Future.delayed(Duration.zero); expect(m.serverIds, isEmpty); expect(m.onlineServerIds, isEmpty); expect(m.offlineServerIds, isEmpty); expect(emitted.last, isEmpty); }); test('clears inactive Jellyfin scoped clients', () { final m = MultiServerManager(); addTearDown(m.dispose); m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-a')); m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-b')); m.disconnectAll(); expect(m.getClient(ServerId('jf-machine')), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull); expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull); }); }); // ============================================================ // dispose // ============================================================ group('dispose', () { test('disposing without connectivity monitoring does not throw', () { final m = MultiServerManager(); // No startNetworkMonitoring call → _connectivitySubscription is null. // dispose() must handle the null-subscription path cleanly. expect(m.dispose, returnsNormally); }); test('dispose closes the status stream (existing subscribers get onDone)', () async { final m = MultiServerManager(); var done = false; final sub = m.statusStream.listen((_) {}, onDone: () => done = true); m.dispose(); // Allow the close event to propagate. await Future.delayed(Duration.zero); expect(done, isTrue); await sub.cancel(); }); }); }