import 'dart:async'; import 'package:plezy/media/ids.dart'; import 'dart:convert'; 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/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/active_profile_binder.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; /// Poll [condition] until it holds, failing after [timeout]. Used to observe /// the binder's unawaited background reconcile settling. Future pumpUntil(Future Function() condition, {Duration timeout = const Duration(seconds: 2)}) async { final deadline = DateTime.now().add(timeout); while (!await condition()) { if (DateTime.now().isAfter(deadline)) { fail('condition not met within $timeout'); } await Future.delayed(const Duration(milliseconds: 10)); } } void main() { late AppDatabase db; late ConnectionRegistry connections; late ProfileConnectionRegistry profileConnections; late ProfileRegistry profiles; late PlexHomeService plexHome; late ActiveProfileProvider activeProfile; late MultiServerManager manager; late MultiServerProvider multiServerProvider; late ActiveProfileBinder binder; late StorageService storage; late bool shouldDeferInitialBind; late List fetchedHomeUsers; setUp(() async { resetSharedPreferencesForTest(); db = AppDatabase.forTesting(NativeDatabase.memory()); connections = ConnectionRegistry(db); profileConnections = ProfileConnectionRegistry(db); profiles = ProfileRegistry(db); storage = await StorageService.getInstance(); fetchedHomeUsers = const []; plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => fetchedHomeUsers, ); activeProfile = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, profileConnections: profileConnections, storage: storage, ); manager = MultiServerManager(); multiServerProvider = testMultiServerProvider(manager); shouldDeferInitialBind = false; binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => shouldDeferInitialBind, ); }); tearDown(() async { binder.dispose(); multiServerProvider.dispose(); await activeProfile.resetForTesting(); activeProfile.dispose(); await plexHome.dispose(); await db.close(); }); Future createActiveLocalProfile(String id) async { final profile = Profile.local(id: id, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); await profiles.upsert(profile); await storage.setActiveProfileId(profile.id); await activeProfile.initialize(); return profile; } test('local profile with no connections binds successfully with empty visibility', () async { final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); await profiles.upsert(profile); await storage.setActiveProfileId(profile.id); await activeProfile.initialize(); await binder.rebindActive(); expect(activeProfile.lastBindingSucceeded, isTrue); expect(binder.debugLastBoundProfileId, profile.id); expect(multiServerProvider.serverIds, isEmpty); }); test('started binder does not loop forever after empty local bind', () async { final profile = Profile.local(id: 'local-empty', displayName: 'Empty', createdAt: DateTime(2026, 1, 1)); await profiles.upsert(profile); await storage.setActiveProfileId(profile.id); await activeProfile.initialize(); var notifications = 0; activeProfile.addListener(() => notifications++); binder.start(); await Future.delayed(const Duration(milliseconds: 50)); expect(activeProfile.isBinding, isFalse); expect(activeProfile.lastBindingSucceeded, isTrue); expect(binder.debugLastBoundProfileId, profile.id); expect(notifications, lessThan(8)); }); test('start() marks binding synchronously so first-frame readers see it', () async { await createActiveLocalProfile('local-sync-start'); binder.start(); // Read with no await in between — mirrors DiscoverScreen's no-servers // gate running during the first build right after a fresh login. expect(activeProfile.isBinding, isTrue); final succeeded = await activeProfile.awaitBindingSettle(); expect(succeeded, isTrue); expect(activeProfile.isBinding, isFalse); expect(binder.debugLastBoundProfileId, 'local-sync-start'); }); test('disposing before the initial rebind microtask clears the binding flag', () async { await createActiveLocalProfile('local-dispose-early'); binder.start(); expect(activeProfile.isBinding, isTrue); binder.dispose(); // Let the scheduled microtask observe the disposal. await Future.delayed(Duration.zero); expect(activeProfile.isBinding, isFalse); }); test('initial bind can be deferred until profile selection', () async { final profile = await createActiveLocalProfile('local-deferred'); shouldDeferInitialBind = true; await binder.rebindActive(); expect(activeProfile.lastBindingSucceeded, isTrue); expect(activeProfile.isBinding, isFalse); expect(binder.debugLastBoundProfileId, isNull); expect(binder.consumeUserInitiatedActivation(profile.id), isFalse); expect(multiServerProvider.serverIds, isEmpty); }); test('user initiated activation bypasses initial bind defer', () async { final profile = await createActiveLocalProfile('local-user-initiated'); shouldDeferInitialBind = true; binder.markUserInitiatedActivation(profile.id); await binder.rebindActive(); expect(activeProfile.lastBindingSucceeded, isTrue); expect(binder.debugLastBoundProfileId, profile.id); expect(binder.consumeUserInitiatedActivation(profile.id), isFalse); }); test('tracks expected Plex server ids when bind cannot create a live client', () async { binder.dispose(); multiServerProvider.dispose(); final failingManager = _FailingPlexMultiServerManager(); manager = failingManager; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, plexAuth: PlexAuthService.forTesting( http: MediaServerHttpClient( client: MockClient( (_) async => http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}), ), ), ), ); final profile = await createActiveLocalProfile('local-plex-offline'); final account = PlexAccountConnection( id: 'plex.account', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', servers: [_server(accessToken: 'account-server-token')], createdAt: DateTime(2026, 1, 1), ); await connections.upsert(account); await profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: account.id, userToken: 'home-user-token', userIdentifier: 'home-user-uuid', tokenAcquiredAt: DateTime(2026, 1, 1), ), ); await binder.rebindActive(); expect(activeProfile.lastBindingSucceeded, isFalse); // The account-scoped cached PMS token differs from this profile token, // so only the freshly fetched resource list reaches the manager. expect(failingManager.refreshCalls, 1); expect(multiServerProvider.serverIds, isEmpty); expect(multiServerProvider.expectedServerIds, ['srv-1']); }); test('binds Plex and Jellyfin join rows in parallel', () async { binder.dispose(); multiServerProvider.dispose(); final mixedManager = _BlockingMixedMultiServerManager(); manager = mixedManager; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, plexAuth: PlexAuthService.forTesting( http: MediaServerHttpClient( client: MockClient( (_) async => http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}), ), ), ), ); final profile = await createActiveLocalProfile('local-mixed'); final plexAccount = PlexAccountConnection( id: 'plex.account', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', servers: [_server(accessToken: 'account-server-token')], createdAt: DateTime(2026, 1, 1), ); final jellyfin = _jellyfinConnection(); await connections.upsert(plexAccount); await connections.upsert(jellyfin); await profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: plexAccount.id, userToken: 'home-user-token', userIdentifier: 'home-user-uuid', tokenAcquiredAt: DateTime(2026, 1, 1), ), ); await profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId, tokenAcquiredAt: DateTime(2026, 1, 1), ), ); final bind = binder.rebindActive(); await mixedManager.plexStarted.future; await Future.delayed(Duration.zero); expect(mixedManager.jellyfinStarted.isCompleted, isTrue); expect(activeProfile.isBinding, isTrue); mixedManager.releasePlex.complete(); await bind; expect(activeProfile.lastBindingSucceeded, isTrue); expect(multiServerProvider.onlineServerIds, ['jf-machine']); expect(multiServerProvider.expectedServerIds.toSet(), {'srv-1', 'jf-machine'}); }); group('Plex Home token cache policy', () { test('cold start uses cached token instead of forcing PIN revalidation', () { expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false), isTrue); }); test('preverified activation uses cache once regardless of setting', () { expect(shouldUsePlexHomeTokenCache(preVerified: true, hasBoundOnce: false), isTrue); }); test('user-initiated switches bypass cache after first bind', () { expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: true), isFalse); }); test('preverified activation flag is consumed once per profile', () { expect(binder.consumePlexHomePreVerified('plex-home-x'), isFalse); binder.markPlexHomePreVerified('plex-home-x'); expect(binder.consumePlexHomePreVerified('plex-home-x'), isTrue); expect(binder.consumePlexHomePreVerified('plex-home-x'), isFalse); }); test('preverified activation flag isolates entries per profile id', () { binder.markPlexHomePreVerified('plex-home-a'); binder.markPlexHomePreVerified('plex-home-b'); expect(binder.consumePlexHomePreVerified('plex-home-b'), isTrue); expect(binder.consumePlexHomePreVerified('plex-home-a'), isTrue); }); }); group('Plex Home server refresh fallback', () { Future<({String profileId, _CapturingMultiServerManager manager})> preparePlexHomeBind({ required bool protected, required http.Client httpClient, List? cachedServers, }) async { binder.dispose(); multiServerProvider.dispose(); final capturingManager = _CapturingMultiServerManager(); manager = capturingManager; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)), ); final account = PlexAccountConnection( id: 'plex.account', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', servers: cachedServers ?? [_server(accessToken: 'home-user-token')], createdAt: DateTime(2026, 1, 1), ); await connections.upsert(account); final homeUser = PlexHomeUser( id: 1, uuid: 'home-user-uuid', title: 'Home User', thumb: '', hasPassword: protected, restricted: false, updatedAt: null, admin: true, guest: false, protected: protected, ); fetchedHomeUsers = [homeUser]; final profileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: homeUser.uuid); await storage.savePlexHomeUsersCache(account.id, [homeUser.toJson()]); await profileConnections.upsert( ProfileConnection( profileId: profileId, connectionId: account.id, userToken: 'home-user-token', userIdentifier: homeUser.uuid, tokenAcquiredAt: DateTime(2026, 1, 1), ), ); await storage.setActiveProfileId(profileId); await activeProfile.initialize(); return (profileId: profileId, manager: capturingManager); } test('uses cached server metadata with the active user token after transient resources failure', () async { final prepared = await preparePlexHomeBind( protected: false, httpClient: MockClient((request) async { throw http.ClientException('DNS failed', request.url); }), ); await binder.rebindActive(); expect(activeProfile.lastBindingSucceeded, isTrue); expect(binder.debugLastBoundProfileId, prepared.profileId); expect(prepared.manager.refreshCalls, 1); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token'); expect(prepared.manager.lastConnection?.servers.single.clientIdentifier, 'srv-1'); expect(prepared.manager.lastProfileId, prepared.profileId); }); test('binds from cache when plex.tv rejects the token, then flags re-auth from the reconcile', () async { final prepared = await preparePlexHomeBind( protected: true, httpClient: MockClient((request) async { return http.Response('{"errors":[]}', 401, headers: {'content-type': 'application/json'}); }), ); await binder.rebindActive(); // The optimistic cached bind settles first — content stays available // (mirrors the cold-start cache policy: the cached token was valid // when minted; the splash must not block on plex.tv's verdict). expect(activeProfile.lastBindingSucceeded, isTrue); expect(prepared.manager.refreshCalls, 1); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token'); expect(prepared.manager.lastProfileId, prepared.profileId); // The background reconcile sees the 401: wipes the cached token and // flags the account for re-auth — no silent /switch re-mint that // could pop a PIN prompt from a background task. await pumpUntil(() async { final pc = await profileConnections.get(prepared.profileId, 'plex.account'); return pc?.userToken == null; }); await pumpUntil(() async => manager.authErrorServerIds.contains('srv-1')); // No second connect pass was attempted with the rejected token. expect(prepared.manager.refreshCalls, 1); }); test('optimistic cached bind settles without waiting for the resource refresh, then reconciles', () async { final fetchGate = Completer(); final prepared = await preparePlexHomeBind( protected: false, httpClient: MockClient((request) async { await fetchGate.future; return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}); }), ); // Settles while plex.tv still hasn't answered. await binder.rebindActive().timeout(const Duration(seconds: 2)); expect(activeProfile.lastBindingSucceeded, isTrue); expect(binder.debugLastBoundProfileId, prepared.profileId); expect(prepared.manager.refreshCalls, 1); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token'); fetchGate.complete(); // Same membership → the reconcile rotates in the freshly fetched // per-server tokens in place. await pumpUntil(() async => prepared.manager.refreshCalls == 2); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'server-token'); expect(prepared.manager.lastProfileId, prepared.profileId); // And the refreshed metadata was persisted onto the stored account row. final account = await connections.getPlexAccount('plex.account'); expect(account?.servers.single.accessToken, 'server-token'); }); test('defers distinct PMS resource tokens until plex.tv refreshes them', () async { final fetchStarted = Completer(); final fetchGate = Completer(); final prepared = await preparePlexHomeBind( protected: false, cachedServers: [ _server(clientIdentifier: 'srv-direct', accessToken: 'home-user-token'), _server(clientIdentifier: 'srv-shared', accessToken: 'cached-shared-pms-token', owned: false, local: false), ], httpClient: MockClient((request) async { if (!fetchStarted.isCompleted) fetchStarted.complete(); await fetchGate.future; return http.Response( jsonEncode([ _serverJson(clientIdentifier: 'srv-direct', accessToken: 'fresh-direct-pms-token'), _serverJson( clientIdentifier: 'srv-shared', accessToken: 'fresh-shared-pms-token', owned: false, local: false, ), ]), 200, headers: {'content-type': 'application/json'}, ); }), ); final bind = binder.rebindActive(); await fetchStarted.future.timeout(const Duration(seconds: 2)); await pumpUntil(() async => prepared.manager.refreshCalls == 1); // The account-scoped cached token for the shared PMS may belong to a // different Plex Home profile. Do not replace it with the active // plex.tv token and probe the PMS: shared servers reject that as 401. expect(prepared.manager.lastConnection?.servers.map((server) => server.clientIdentifier), ['srv-direct']); expect(activeProfile.isBinding, isTrue); fetchGate.complete(); await bind.timeout(const Duration(seconds: 2)); expect(prepared.manager.refreshCalls, 2); final refreshedServers = { for (final server in prepared.manager.lastConnection!.servers) server.clientIdentifier: server.accessToken, }; expect(refreshedServers, {'srv-direct': 'fresh-direct-pms-token', 'srv-shared': 'fresh-shared-pms-token'}); expect(activeProfile.lastBindingSucceeded, isTrue); }); test('membership change in the background refresh triggers a full rebind', () async { final fetchGate = Completer(); final prepared = await preparePlexHomeBind( protected: false, httpClient: MockClient((request) async { await fetchGate.future; return http.Response( jsonEncode([_serverJson(clientIdentifier: 'srv-2')]), 200, headers: {'content-type': 'application/json'}, ); }), ); await binder.rebindActive().timeout(const Duration(seconds: 2)); expect(prepared.manager.refreshCalls, 1); expect(prepared.manager.lastConnection?.servers.single.clientIdentifier, 'srv-1'); fetchGate.complete(); // Fresh membership {srv-2} ≠ cached {srv-1}: the reconcile persists the // new resource list and triggers a full rebind, which reuses the // just-validated cached token (pre-verified — no /switch round-trip) // and binds the new membership. await pumpUntil(() async => prepared.manager.lastConnection?.servers.single.clientIdentifier == 'srv-2'); final account = await connections.getPlexAccount('plex.account'); expect(account?.servers.single.clientIdentifier, 'srv-2'); expect(binder.debugLastBoundProfileId, prepared.profileId); // The rebind sees that the account-level PMS token differs from the // active profile token, so it waits for `/resources` and binds once // with the refreshed token instead of doing another optimistic pass. await pumpUntil(() async => prepared.manager.refreshCalls >= 2); await Future.delayed(const Duration(milliseconds: 50)); expect(prepared.manager.refreshCalls, 2); }); }); test('local cached Plex token remints when resources returns zero servers', () async { binder.dispose(); multiServerProvider.dispose(); var resourceCalls = 0; var switchCalls = 0; final httpClient = MockClient((request) async { if (request.url.path.endsWith('/resources')) { resourceCalls++; final body = resourceCalls == 1 ? [] : [_serverJson()]; return http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); } if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) { switchCalls++; return http.Response( jsonEncode({'authToken': 'fresh-user-token'}), 201, headers: {'content-type': 'application/json'}, ); } fail('Unexpected request: ${request.method} ${request.url}'); }); final recoveringManager = _RecordingPlexManager(); manager = recoveringManager; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)), ); final account = PlexAccountConnection( id: 'plex.account', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', servers: [_server(accessToken: 'stale-server-token')], createdAt: DateTime(2026, 1, 1), ); await connections.upsert(account); final profile = await createActiveLocalProfile('local-plex-remint'); await profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: account.id, userToken: 'stale-user-token', userIdentifier: 'home-user-uuid', ), ); await binder.rebindActive(); final row = await profileConnections.get(profile.id, account.id); expect(resourceCalls, 2); expect(switchCalls, 1); expect(row?.userToken, 'fresh-user-token'); expect(recoveringManager.calls, 1); expect(activeProfile.lastBindingSucceeded, isTrue); expect(multiServerProvider.onlineServerIds, ['srv-1']); }); group('shared Plex server fetch policy', () { Future<({Profile profile, PlexAccountConnection account})> prepareLocalPlex({ required http.Client httpClient, required String? cachedToken, List cachedServers = const [], MultiServerManager? testManager, }) async { binder.dispose(); multiServerProvider.dispose(); manager = testManager ?? _CapturingMultiServerManager(); multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)), ); final account = PlexAccountConnection( id: 'plex.policy', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', servers: cachedServers, createdAt: DateTime(2026, 1, 1), ); await connections.upsert(account); final profile = await createActiveLocalProfile('local-policy'); await profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: account.id, userToken: cachedToken, userIdentifier: 'home-user-uuid', ), ); return (profile: profile, account: account); } test('cached token success connects the fetched servers without minting', () async { var switchCalls = 0; final capturing = _CapturingMultiServerManager(); final prepared = await prepareLocalPlex( cachedToken: 'cached-user-token', testManager: capturing, httpClient: MockClient((request) async { if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++; return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}); }), ); await binder.rebindActive(); expect(switchCalls, 0); expect(capturing.refreshCalls, 1); expect(capturing.lastConnection?.servers.single.accessToken, 'server-token'); expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token'); expect(activeProfile.lastBindingSucceeded, isTrue); }); test('missing token mints once before fetching and persists the fresh token', () async { var switchCalls = 0; var resourceCalls = 0; final prepared = await prepareLocalPlex( cachedToken: null, httpClient: MockClient((request) async { if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) { switchCalls++; return http.Response( jsonEncode({'authToken': 'fresh-user-token'}), 201, headers: {'content-type': 'application/json'}, ); } resourceCalls++; return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}); }), ); await binder.rebindActive(); expect(switchCalls, 1); expect(resourceCalls, 1); expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'fresh-user-token'); expect(activeProfile.lastBindingSucceeded, isTrue); }); test('cached auth rejection invalidates and remints, while a fatal error does not', () async { var resourceCalls = 0; var switchCalls = 0; final prepared = await prepareLocalPlex( cachedToken: 'rejected-token', httpClient: MockClient((request) async { if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) { switchCalls++; return http.Response( jsonEncode({'authToken': 'replacement-token'}), 201, headers: {'content-type': 'application/json'}, ); } resourceCalls++; if (resourceCalls == 1) { return http.Response('{}', 401, headers: {'content-type': 'application/json'}); } return http.Response('{}', 500, headers: {'content-type': 'application/json'}); }), ); await binder.rebindActive(); expect(resourceCalls, 2); expect(switchCalls, 1); expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'replacement-token'); expect(activeProfile.lastBindingSucceeded, isFalse); }); test('cancelled fetch does not invalidate or remint the cached token', () async { var switchCalls = 0; final prepared = await prepareLocalPlex( cachedToken: 'cached-user-token', httpClient: MockClient((request) async { if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++; throw http.RequestAbortedException(request.url); }), ); await binder.rebindActive(); expect(switchCalls, 0); expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token'); expect(activeProfile.lastBindingSucceeded, isFalse); }); test('profile switch cancels stale zero-server handling before invalidation or remint', () async { final requestStarted = Completer(); final releaseRequest = Completer(); var switchCalls = 0; final prepared = await prepareLocalPlex( cachedToken: 'cached-user-token', httpClient: MockClient((request) async { if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++; if (!requestStarted.isCompleted) requestStarted.complete(); await releaseRequest.future; return http.Response('[]', 200, headers: {'content-type': 'application/json'}); }), ); final nextProfile = Profile.local(id: 'local-next', displayName: 'Next', createdAt: DateTime(2026, 1, 2)); await profiles.upsert(nextProfile); await pumpUntil(() async => activeProfile.profiles.any((profile) => profile.id == nextProfile.id)); binder.start(); await requestStarted.future; await activeProfile.activate(nextProfile); releaseRequest.complete(); await activeProfile.awaitBindingSettle(); expect(switchCalls, 0); expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token'); expect(binder.debugLastBoundProfileId, nextProfile.id); expect(activeProfile.lastBindingSucceeded, isTrue); }); }); group('rebind cycle semantics', () { test('queued same-id rebind settles once, after the last pass', () async { binder.dispose(); multiServerProvider.dispose(); final gated = _GatedJellyfinManager(); manager = gated; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, ); final profile = await createActiveLocalProfile('local-queued'); final jellyfin = _jellyfinConnection(); await connections.upsert(jellyfin); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), ); final cycle = binder.rebindActive(); await pumpUntil(() async => gated.calls == 1); int? callsAtSettle; unawaited(activeProfile.awaitBindingSettle().then((_) => callsAtSettle = gated.calls)); unawaited(binder.rebindActive()); // queues a same-id follow-up pass gated.gate.complete(); await cycle; await Future.delayed(Duration.zero); expect(gated.calls, 2); // Waiters must observe the whole cycle, not the first pass's outcome. expect(callsAtSettle, 2); expect(activeProfile.isBinding, isFalse); }); test('A to B to A during one pass forces a complete final A bind', () async { binder.dispose(); multiServerProvider.dispose(); final gated = _GatedJellyfinManager(); manager = gated; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, ); final profileA = await createActiveLocalProfile('local-a'); final profileB = Profile.local(id: 'local-b', displayName: 'B', createdAt: DateTime(2026, 1, 2)); await profiles.upsert(profileB); await pumpUntil(() async => activeProfile.profiles.any((profile) => profile.id == profileB.id)); final jellyfin = _jellyfinConnection(); await connections.upsert(jellyfin); await profileConnections.upsert( ProfileConnection(profileId: profileA.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), ); binder.start(); await pumpUntil(() async => gated.calls == 1); expect(await activeProfile.activate(profileB), isTrue); expect(await activeProfile.activate(profileA), isTrue); gated.gate.complete(); await activeProfile.awaitBindingSettle(); expect(gated.calls, 2); expect(binder.debugLastBoundProfileId, profileA.id); expect(multiServerProvider.onlineServerIds, ['jf-machine']); expect(activeProfile.lastBindingSucceeded, isTrue); }); test('passive notifications do not retry a failed profile; explicit rebind does', () async { binder.dispose(); multiServerProvider.dispose(); final failing = _CountingFailingJellyfinManager(); manager = failing; multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async => null, shouldDeferInitialBind: (_) async => false, ); final profile = await createActiveLocalProfile('local-failing'); final jellyfin = _jellyfinConnection(); await connections.upsert(jellyfin); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), ); binder.start(); await pumpUntil(() async => failing.calls == 1 && !activeProfile.isBinding); expect(activeProfile.lastBindingSucceeded, isFalse); // A passive data change (an unrelated connection appearing) must not // re-run the failed bind — mid-session retries can pop PIN prompts. await connections.upsert(_jellyfinConnection2()); await Future.delayed(const Duration(milliseconds: 50)); expect(failing.calls, 1); // An explicit rebind clears the marker and retries. await binder.rebindActive(); expect(failing.calls, greaterThan(1)); }); test('passive rebind of a protected Plex Home profile never prompts for a PIN', () async { binder.dispose(); multiServerProvider.dispose(); var pinPrompts = 0; manager = _CapturingMultiServerManager(); multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, profileConnections: profileConnections, serverManager: manager, multiServerProvider: multiServerProvider, pinPrompt: (_, {String? errorMessage}) async { pinPrompts++; return null; }, shouldDeferInitialBind: (_) async => false, plexAuth: PlexAuthService.forTesting( http: MediaServerHttpClient(client: MockClient((_) async => http.Response('{}', 500))), ), ); final account = PlexAccountConnection( id: 'plex.account', accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', servers: [_server(accessToken: 'account-server-token')], createdAt: DateTime(2026, 1, 1), ); await connections.upsert(account); final homeUser = PlexHomeUser( id: 1, uuid: 'protected-uuid', title: 'Protected', thumb: '', hasPassword: true, restricted: false, updatedAt: null, admin: false, guest: false, protected: true, ); fetchedHomeUsers = [homeUser]; await storage.savePlexHomeUsersCache(account.id, [homeUser.toJson()]); // Bind a harmless local profile first so the session crosses the // cold-start boundary (_hasBoundOnce) — the state in which passive // rebinds would otherwise PIN-prompt via /switch. await createActiveLocalProfile('local-first'); binder.start(); await pumpUntil(() async => !activeProfile.isBinding && binder.debugLastBoundProfileId == 'local-first'); // Activation WITHOUT the user-initiated mark: the binder must fail the // bind silently instead of popping a PIN dialog. final protectedProfile = Profile.virtualPlexHome(connectionId: account.id, homeUser: homeUser); await activeProfile.activate(protectedProfile); await pumpUntil(() async => !activeProfile.isBinding); expect(pinPrompts, 0); expect(activeProfile.lastBindingSucceeded, isFalse); // The same switch marked user-initiated prompts (and the spy cancels). binder.markUserInitiatedActivation(protectedProfile.id); await binder.rebindActive(); expect(pinPrompts, 1); }); }); } PlexServer _server({ required String accessToken, String clientIdentifier = 'srv-1', bool owned = true, bool local = true, }) { return PlexServer( name: 'Home Server', clientIdentifier: clientIdentifier, accessToken: accessToken, connections: [ PlexConnection( protocol: 'https', address: '192.168.1.3', port: 32400, uri: 'https://192-168-1-3.machine.plex.direct:32400', local: local, relay: false, ipv6: false, ), ], owned: owned, presence: true, ); } Map _serverJson({ String clientIdentifier = 'srv-1', String accessToken = 'server-token', bool owned = true, bool local = true, }) => { 'name': 'Home Server', 'clientIdentifier': clientIdentifier, 'accessToken': accessToken, 'owned': owned, 'provides': 'server', 'connections': [ { 'protocol': 'https', 'address': '192.168.1.3', 'port': 32400, 'uri': 'https://192-168-1-3.machine.plex.direct:32400', 'local': local, 'relay': false, 'IPv6': false, }, ], }; JellyfinConnection _jellyfinConnection() { return JellyfinConnection( id: 'jf-machine/user-a', baseUrl: 'https://jellyfin.example', serverName: 'Jellyfin', serverMachineId: 'jf-machine', userId: 'user-a', userName: 'User A', accessToken: 'token', deviceId: 'device', createdAt: DateTime(2026, 1, 1), ); } JellyfinConnection _jellyfinConnection2() { return JellyfinConnection( id: 'jf-other/user-b', baseUrl: 'https://other.example', serverName: 'Other', serverMachineId: 'jf-other', userId: 'user-b', userName: 'User B', accessToken: 'token-b', deviceId: 'device', createdAt: DateTime(2026, 1, 2), ); } class _GatedJellyfinManager extends MultiServerManager { final Completer gate = Completer(); int calls = 0; @override Future addJellyfinConnection(JellyfinConnection connection) async { calls++; if (calls == 1) await gate.future; updateServerStatus(ServerId(connection.serverMachineId), true); return true; } } class _CountingFailingJellyfinManager extends MultiServerManager { int calls = 0; @override Future addJellyfinConnection(JellyfinConnection connection) async { calls++; updateServerStatus(ServerId(connection.serverMachineId), false); return false; } } class _CapturingMultiServerManager extends MultiServerManager { int refreshCalls = 0; PlexAccountConnection? lastConnection; String? lastProfileId; @override Future> refreshTokensForProfile( PlexAccountConnection connection, { required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { refreshCalls++; lastConnection = connection; lastProfileId = profileId; return connection.servers.map((server) => server.clientIdentifier).toSet(); } } class _FailingPlexMultiServerManager extends MultiServerManager { int refreshCalls = 0; @override Future> refreshTokensForProfile( PlexAccountConnection connection, { required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { refreshCalls++; for (final server in connection.servers) { updateServerStatus(ServerId(server.clientIdentifier), false); } return const {}; } } class _RecordingPlexManager extends MultiServerManager { int calls = 0; @override Future> refreshTokensForProfile( PlexAccountConnection connection, { required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { calls++; final ids = connection.servers.map((server) => server.clientIdentifier).toSet(); for (final id in ids) { updateServerStatus(ServerId(id), true); } return ids; } } class _BlockingMixedMultiServerManager extends MultiServerManager { final plexStarted = Completer(); final releasePlex = Completer(); final jellyfinStarted = Completer(); @override Future> refreshTokensForProfile( PlexAccountConnection connection, { required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { if (!plexStarted.isCompleted) plexStarted.complete(); await releasePlex.future; for (final server in connection.servers) { updateServerStatus(ServerId(server.clientIdentifier), false); } return const {}; } @override Future addJellyfinConnection(JellyfinConnection connection) async { if (!jellyfinStarted.isCompleted) jellyfinStarted.complete(); updateServerStatus(ServerId(connection.serverMachineId), true); return true; } }