import 'dart:async'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/models/plex/plex_home.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/companion_remote/remote_command.dart'; import 'package:plezy/models/companion_remote/remote_session.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/companion_remote_provider.dart'; import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart'; import 'package:plezy/services/companion_remote/lan_discovery_service.dart'; import 'package:plezy/services/companion_remote/remote_auth_context.dart'; import 'package:plezy/services/companion_remote/remote_auth_service.dart'; import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(resetSharedPreferencesForTest); group('CompanionRemoteProvider — initial state', () { test('starts with no session and no connected device', () { final p = CompanionRemoteProvider(); expect(p.session, isNull); expect(p.isInSession, isFalse); expect(p.isHost, isFalse); expect(p.isRemote, isFalse); expect(p.isConnected, isFalse); expect(p.connectedDevice, isNull); expect(p.status, RemoteSessionStatus.disconnected); p.dispose(); }); test('isPlayerActive starts false', () { final p = CompanionRemoteProvider(); expect(p.isPlayerActive, isFalse); p.dispose(); }); test('isHostServerRunning starts false (no peer service yet)', () { final p = CompanionRemoteProvider(); expect(p.isHostServerRunning, isFalse); p.dispose(); }); test('reconnectAttempts starts at 0', () { final p = CompanionRemoteProvider(); expect(p.reconnectAttempts, 0); p.dispose(); }); test('isCryptoReady is false until ensureCryptoReady succeeds', () { final p = CompanionRemoteProvider(); expect(p.isCryptoReady, isFalse); p.dispose(); }); test('discoverHosts returns null when crypto is not ready', () { final p = CompanionRemoteProvider(); expect(p.discoverHosts(), isNull); p.dispose(); }); test('sendCommand is a no-op when not connected (no throw)', () { final p = CompanionRemoteProvider(); // Not connected → cannot send. Must log a warning but not throw. expect(() => p.sendCommand(RemoteCommandType.ping), returnsNormally); p.dispose(); }); test('startHostServer no-ops when crypto is not ready', () async { final p = CompanionRemoteProvider(); // Without crypto context, this method must early-return without // creating a peer service or session. await p.startHostServer(); expect(p.session, isNull); expect(p.isHostServerRunning, isFalse); p.dispose(); }); }); group('CompanionRemoteProvider — dispose hygiene', () { test('cancelReconnect on a fresh provider does not throw', () { final p = CompanionRemoteProvider(); // No timer, no session — copyWith on null _session is a no-op so // status remains disconnected. expect(p.cancelReconnect, returnsNormally); expect(p.status, RemoteSessionStatus.disconnected); p.dispose(); }); test('stopDiscovery on a fresh provider is a no-op', () { final p = CompanionRemoteProvider(); expect(p.stopDiscovery, returnsNormally); p.dispose(); }); test('leaveSession on a fresh provider does not throw', () async { final p = CompanionRemoteProvider(); await p.leaveSession(); expect(p.session, isNull); p.dispose(); }); test('safeNotifyListeners no-ops after dispose (deviceInfo race)', () async { // The constructor kicks off an async _initializeDeviceInfo() that calls // safeNotifyListeners() on completion. Disposing before that microtask // resolves must not throw — the disposable mixin should swallow it. final p = CompanionRemoteProvider(); p.dispose(); // Yield so any pending device-info callbacks complete. await Future.delayed(Duration.zero); }); }); group('CompanionRemoteProvider — reconnect ownership', () { test('newest overlapping connect owns the session and disposes the stale candidate once', () async { final firstJoinGate = Completer(); final firstDisposalGate = Completer(); final first = _FakeCompanionRemotePeerService(joinGate: firstJoinGate, disconnectGate: firstDisposalGate); final second = _FakeCompanionRemotePeerService(); final factory = _FakePeerFactory([first, second]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); final olderConnect = harness.provider.connectToManualHost('192.0.2.20:48634'); await first.joinStarted.future; final newerConnect = harness.provider.connectToManualHost('192.0.2.21:48634'); await first.disconnectStarted.future; firstJoinGate.complete(); await Future.delayed(Duration.zero); expect(first.disposeCalls, 1); firstDisposalGate.complete(); await second.joinStarted.future; await newerConnect; harness.provider.sendCommand(RemoteCommandType.playPause); expect(harness.provider.status, RemoteSessionStatus.connected); expect(second.sentCommands.single.type, RemoteCommandType.playPause); await olderConnect; expect(harness.provider.status, RemoteSessionStatus.connected); expect(first.disposeCalls, 1); expect(first.disconnectCalls, 1); expect(first.hasListeners, isFalse); expect(second.disposeCalls, 0); expect(factory.created, 2); }); test('replacement disconnect reconnects while intentional predecessor teardown is blocked', () async { final predecessorDisposalGate = Completer(); final predecessor = _FakeCompanionRemotePeerService(disconnectGate: predecessorDisposalGate); final replacement = _FakeCompanionRemotePeerService(); final factory = _FakePeerFactory([predecessor, replacement]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); Future? predecessorTeardown; addTearDown(() async { if (!predecessorDisposalGate.isCompleted) { predecessorDisposalGate.complete(); } final teardown = predecessorTeardown; if (teardown != null) await teardown; }); await harness.provider.connectToManualHost('192.0.2.22:48634'); var teardownCompleted = false; predecessorTeardown = harness.provider.leaveSession().whenComplete(() { teardownCompleted = true; }); await predecessor.disconnectStarted.future; expect(teardownCompleted, isFalse); await harness.provider.connectToManualHost('192.0.2.23:48634'); expect(harness.provider.status, RemoteSessionStatus.connected); expect(replacement.hasListeners, isTrue); final publishedStatuses = []; void captureStatus() => publishedStatuses.add(harness.provider.status); harness.provider.addListener(captureStatus); predecessor.emitError( RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'stale predecessor error'), ); predecessor.emitDeviceDisconnected(); expect(publishedStatuses, isEmpty); expect(harness.provider.status, RemoteSessionStatus.connected); expect(harness.provider.session?.errorMessage, isNull); expect(harness.provider.reconnectAttempts, 0); replacement.emitDeviceDisconnected(); expect(publishedStatuses, [RemoteSessionStatus.reconnecting]); expect(harness.provider.status, RemoteSessionStatus.reconnecting); expect(harness.provider.reconnectAttempts, 1); expect(teardownCompleted, isFalse); predecessorDisposalGate.complete(); await predecessorTeardown; expect(publishedStatuses, [RemoteSessionStatus.reconnecting]); expect(harness.provider.status, RemoteSessionStatus.reconnecting); expect(harness.provider.reconnectAttempts, 1); harness.provider.removeListener(captureStatus); await harness.provider.cancelReconnect(); }); test('cancelReconnect fully tears down a running host and discovery', () async { final hostDisposalGate = Completer(); final host = _FakeCompanionRemotePeerService(disconnectGate: hostDisposalGate); final discovery = _FakeLanDiscoveryService(); final factory = _FakePeerFactory([host]); final harness = await _RemoteHarness.create(factory.call, discoveryServiceFactory: () => discovery); addTearDown(harness.close); await harness.provider.startHostServer(); expect(harness.provider.isHostServerRunning, isTrue); expect(harness.provider.debugIsDiscoveryBroadcasting, isTrue); expect(host.hasListeners, isTrue); final cancellation = harness.provider.cancelReconnect(); await host.disconnectStarted.future; expect(harness.provider.debugIsDiscoveryBroadcasting, isFalse); expect(host.hasListeners, isFalse); hostDisposalGate.complete(); await cancellation; expect(harness.provider.session, isNull); expect(harness.provider.isHostServerRunning, isFalse); expect(harness.provider.debugIsDiscoveryBroadcasting, isFalse); expect(harness.provider.debugIsDiscoveryListening, isFalse); expect(discovery.stopBroadcastingCalls, 1); expect(discovery.stopListeningCalls, 1); expect(host.disposeCalls, 1); expect(host.disconnectCalls, 1); expect(host.hasListeners, isFalse); }); test('cancel during join keeps disconnected state and disposes the candidate once', () async { final initial = _FakeCompanionRemotePeerService(); final joinGate = Completer(); final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate); final factory = _FakePeerFactory([initial, candidate]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.10:48634'); final statuses = []; harness.provider.addListener(() => statuses.add(harness.provider.status)); initial.emitDeviceDisconnected(); expect(harness.provider.status, RemoteSessionStatus.reconnecting); final retry = harness.provider.retryReconnectNow(); await candidate.joinStarted.future; expect(candidate.hasListeners, isTrue); final cancellation = harness.provider.cancelReconnect(); expect(harness.provider.status, RemoteSessionStatus.disconnected); await cancellation; final statusCountAfterCancel = statuses.length; joinGate.complete(); await retry; await Future.delayed(Duration.zero); expect(harness.provider.status, RemoteSessionStatus.disconnected); expect(statuses.skip(statusCountAfterCancel), isNot(contains(RemoteSessionStatus.connected))); expect(candidate.disposeCalls, 1); expect(candidate.disconnectCalls, 1); expect(candidate.hasListeners, isFalse); expect(factory.created, 2); expect(harness.provider.reconnectAttempts, 0); }); test('leave during join clears the session and rejects late commands', () async { final initial = _FakeCompanionRemotePeerService(); final joinGate = Completer(); final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate); final factory = _FakePeerFactory([initial, candidate]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.11:48634'); var deliveredCommands = 0; harness.provider.onCommandReceived = (_) => deliveredCommands++; initial.emitDeviceDisconnected(); final retry = harness.provider.retryReconnectNow(); await candidate.joinStarted.future; await harness.provider.leaveSession(); expect(harness.provider.session, isNull); expect(candidate.hasListeners, isFalse); candidate.emitCommand(const RemoteCommand(type: RemoteCommandType.playPause)); joinGate.complete(); await retry; expect(harness.provider.session, isNull); expect(deliveredCommands, 0); expect(candidate.disposeCalls, 1); expect(factory.created, 2); expect(harness.provider.reconnectAttempts, 0); }); for (final action in ['cancel', 'leave']) { test('$action before candidate creation prevents replacement allocation', () async { final disconnectGate = Completer(); final initial = _FakeCompanionRemotePeerService(disconnectGate: disconnectGate); final replacement = _FakeCompanionRemotePeerService(); final factory = _FakePeerFactory([initial, replacement]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.12:48634'); initial.emitDeviceDisconnected(); final retry = harness.provider.retryReconnectNow(); await initial.disconnectStarted.future; if (action == 'cancel') { await harness.provider.cancelReconnect(); } else { await harness.provider.leaveSession(); } disconnectGate.complete(); await retry; expect(factory.created, 1); expect(replacement.joinStarted.isCompleted, isFalse); expect(initial.disposeCalls, 1); }); } test('dispose during join detaches listeners and prevents a late commit', () async { final initial = _FakeCompanionRemotePeerService(); final joinGate = Completer(); final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate); final factory = _FakePeerFactory([initial, candidate]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.13:48634'); var deliveredCommands = 0; harness.provider.onCommandReceived = (_) => deliveredCommands++; initial.emitDeviceDisconnected(); final retry = harness.provider.retryReconnectNow(); await candidate.joinStarted.future; harness.provider.dispose(); expect(harness.provider.isDisposed, isTrue); expect(candidate.hasListeners, isFalse); candidate.emitCommand(const RemoteCommand(type: RemoteCommandType.playPause)); joinGate.complete(); await retry; await Future.delayed(Duration.zero); expect(harness.provider.status, isNot(RemoteSessionStatus.connected)); expect(deliveredCommands, 0); expect(candidate.disposeCalls, 1); expect(factory.created, 2); }); test('logout invalidates a held reconnect before clearing identity', () async { final initial = _FakeCompanionRemotePeerService(); final joinGate = Completer(); final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate); final factory = _FakePeerFactory([initial, candidate]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.14:48634'); initial.emitDeviceDisconnected(); final retry = harness.provider.retryReconnectNow(); await candidate.joinStarted.future; await harness.provider.resetForLogout(); expect(harness.provider.session, isNull); expect(harness.provider.isCryptoReady, isFalse); expect(harness.provider.debugCryptoConnectionId, isNull); expect(candidate.hasListeners, isFalse); joinGate.complete(); await retry; expect(harness.provider.session, isNull); expect(harness.provider.isCryptoReady, isFalse); expect(candidate.disposeCalls, 1); expect(factory.created, 2); expect(harness.provider.reconnectAttempts, 0); }); test('current reconnect failure is contained and schedules one retry', () async { final initial = _FakeCompanionRemotePeerService(); final candidate = _FakeCompanionRemotePeerService(joinError: StateError('synthetic reconnect failure')); final factory = _FakePeerFactory([initial, candidate]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.15:48634'); initial.emitDeviceDisconnected(); await harness.provider.retryReconnectNow(); expect(harness.provider.status, RemoteSessionStatus.reconnecting); expect(harness.provider.reconnectAttempts, 1); expect(candidate.disposeCalls, 1); expect(candidate.hasListeners, isFalse); await harness.provider.cancelReconnect(); }); test('successful current reconnect preserves connected behavior', () async { final initial = _FakeCompanionRemotePeerService(); final candidate = _FakeCompanionRemotePeerService(); final factory = _FakePeerFactory([initial, candidate]); final harness = await _RemoteHarness.create(factory.call); addTearDown(harness.close); await harness.provider.connectToManualHost('192.0.2.16:48634'); initial.emitDeviceDisconnected(); await harness.provider.retryReconnectNow(); harness.provider.sendCommand(RemoteCommandType.playPause); expect(harness.provider.status, RemoteSessionStatus.connected); expect(harness.provider.reconnectAttempts, 0); expect(candidate.sentCommands.single.type, RemoteCommandType.playPause); expect(initial.disposeCalls, 1); expect(candidate.disposeCalls, 0); }); }); group('CompanionRemoteProvider — public API safety', () { test('connectToDiscoveredHost reports localized auth failure when crypto is not ready', () async { final p = CompanionRemoteProvider(); await expectLater( () => p.connectToManualHost('192.0.2.1:9999'), throwsA( isA().having((error) => error.message, 'message', t.companionRemote.pairing.cryptoInitFailed), ), ); p.dispose(); }); }); group('CompanionRemoteProvider — crypto identity', () { test('Jellyfin remote secret is stable across tokens for the same server user', () async { final auth = RemoteAuthService.instance; auth.clearCache(); final tokenA = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-a'); final tokenAAgain = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-a'); final tokenB = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-a'); final otherUser = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-b'); expect(tokenAAgain, tokenA); expect(tokenB, tokenA); expect(otherUser, isNot(tokenA)); }); test('ensureCryptoReady rebuilds when the active profile/account changes', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); final profileConnections = ProfileConnectionRegistry(db); final profiles = ProfileRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final active = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); addTearDown(() async { await active.resetForTesting(); active.dispose(); await plexHome.dispose(); await db.close(); }); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profileA = _localProfile('profile-a'); final profileB = _localProfile('profile-b'); await connections.upsert(accountA); await connections.upsert(accountB); await profiles.upsert(profileA); await profiles.upsert(profileB); await profileConnections.upsert( ProfileConnection(profileId: profileA.id, connectionId: accountA.id, userIdentifier: 'admin-a'), makeDefault: true, ); await profileConnections.upsert( ProfileConnection(profileId: profileB.id, connectionId: accountB.id, userIdentifier: 'admin-b'), makeDefault: true, ); await storage.setActiveProfileId(profileA.id); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final okA = await provider.ensureCryptoReady( _home('admin-a'), connections: connections, activeProfile: active, profileConnections: profileConnections, account: accountA, ); expect(okA, isTrue); expect(provider.debugCryptoConnectionId, accountA.id); expect(provider.debugCryptoProfileId, profileA.id); await active.activate(profileB); final ok = await provider.ensureCryptoReady( _home('admin-b'), connections: connections, activeProfile: active, profileConnections: profileConnections, account: accountB, ); expect(ok, isTrue); expect(provider.debugCryptoConnectionId, accountB.id); expect(provider.debugCryptoProfileId, profileB.id); }); test('ensureCryptoReady uses the active local profile Plex row', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); final profileConnections = ProfileConnectionRegistry(db); final profiles = ProfileRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final active = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); addTearDown(() async { await active.resetForTesting(); active.dispose(); await plexHome.dispose(); await db.close(); }); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profile = _localProfile('profile-local'); await connections.upsert(accountA); await connections.upsert(accountB); await profiles.upsert(profile); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: accountB.id, userIdentifier: 'child-b', isDefault: true), makeDefault: true, ); await storage.setActiveProfileId(profile.id); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _homeWithUsers('admin-b', ['child-b']), connections: connections, activeProfile: active, profileConnections: profileConnections, ); expect(ok, isTrue); expect(provider.debugCryptoConnectionId, accountB.id); expect(provider.debugCryptoProfileId, profile.id); expect(provider.debugCryptoUserUuid, 'child-b'); }); test('ensureCryptoReady uses the active local profile Jellyfin row', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); final profileConnections = ProfileConnectionRegistry(db); final profiles = ProfileRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final active = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); addTearDown(() async { await active.resetForTesting(); active.dispose(); await plexHome.dispose(); await db.close(); }); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-jf'); await connections.upsert(jellyfin); await profiles.upsert(profile); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); await storage.setActiveProfileId(profile.id); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( null, connections: connections, activeProfile: active, profileConnections: profileConnections, ); expect(ok, isTrue); expect(provider.debugCryptoConnectionId, jellyfin.id); expect(provider.debugCryptoProfileId, profile.id); expect(provider.debugCryptoUserUuid, jellyfin.userId); }); test('ensureCryptoReady includes every active local profile remote identity', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); final profileConnections = ProfileConnectionRegistry(db); final profiles = ProfileRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final active = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); addTearDown(() async { await active.resetForTesting(); active.dispose(); await plexHome.dispose(); await db.close(); }); final account = _plexAccount('plex-a', 'client-a'); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-mixed'); final home = _homeWithUsers('admin-a', ['child-a']); await connections.upsert(account); await connections.upsert(jellyfin); await profiles.upsert(profile); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'child-a'), ); await storage.setActiveProfileId(profile.id); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( home, connections: connections, activeProfile: active, profileConnections: profileConnections, plexHomeForConnection: (_) async => home, ); expect(ok, isTrue); expect(provider.debugCryptoConnectionId, jellyfin.id); expect(provider.debugCryptoConnectionIds, [jellyfin.id, account.id]); }); test('ensureCryptoReady does not fall back to an account without an active profile', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); final profileConnections = ProfileConnectionRegistry(db); final profiles = ProfileRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final active = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); addTearDown(() async { await active.resetForTesting(); active.dispose(); await plexHome.dispose(); await db.close(); }); await connections.upsert(_plexAccount('plex-a', 'client-a')); await profiles.upsert(_localProfile('profile-a')); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _home('admin-a'), connections: connections, activeProfile: active, profileConnections: profileConnections, ); expect(ok, isFalse); expect(provider.isCryptoReady, isFalse); }); test('resetForLogout clears crypto context', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); final profileConnections = ProfileConnectionRegistry(db); final profiles = ProfileRegistry(db); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final active = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); addTearDown(() async { await active.resetForTesting(); active.dispose(); await plexHome.dispose(); await db.close(); }); final account = _plexAccount('plex-a', 'client-a'); final profile = _localProfile('profile-a'); await connections.upsert(account); await profiles.upsert(profile); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'admin-a'), makeDefault: true, ); await storage.setActiveProfileId(profile.id); await active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); await provider.ensureCryptoReady( _home('admin-a'), connections: connections, activeProfile: active, profileConnections: profileConnections, account: account, ); expect(provider.isCryptoReady, isTrue); await provider.resetForLogout(); expect(provider.isCryptoReady, isFalse); expect(provider.debugCryptoConnectionId, isNull); expect(provider.debugCryptoProfileId, isNull); }); }); } PlexAccountConnection _plexAccount(String id, String clientIdentifier) { return PlexAccountConnection( id: id, accountToken: 'token-$id', clientIdentifier: clientIdentifier, accountLabel: id, createdAt: DateTime(2026, 1, 1), ); } JellyfinConnection _jellyfinConnection(String id) { return JellyfinConnection( id: id, baseUrl: 'https://jellyfin.example.test', serverName: 'Jellyfin', serverMachineId: 'machine-$id', userId: 'user-$id', userName: 'User $id', accessToken: 'token-$id', deviceId: 'device-$id', createdAt: DateTime(2026, 1, 1), ); } Profile _localProfile(String id) { return Profile.local(id: id, displayName: id, createdAt: DateTime(2026, 1, 1)); } PlexHome _home(String adminUuid) { return PlexHome( id: 1, name: 'Home', guestUserID: null, guestUserUUID: '', guestEnabled: false, subscription: false, users: [_homeUser(adminUuid, admin: true)], ); } PlexHome _homeWithUsers(String adminUuid, List userUuids) { return PlexHome( id: 1, name: 'Home', guestUserID: null, guestUserUUID: '', guestEnabled: false, subscription: false, users: [_homeUser(adminUuid, admin: true), for (final uuid in userUuids) _homeUser(uuid, admin: false)], ); } PlexHomeUser _homeUser(String uuid, {required bool admin}) { return PlexHomeUser( id: admin ? 1 : 2, uuid: uuid, title: uuid, thumb: '', hasPassword: false, restricted: false, updatedAt: null, admin: admin, guest: false, protected: false, ); } class _FakePeerFactory { _FakePeerFactory(this.peers); final List<_FakeCompanionRemotePeerService> peers; int created = 0; CompanionRemotePeerService call() { if (created >= peers.length) { throw StateError('Unexpected peer allocation'); } return peers[created++]; } } class _FakeCompanionRemotePeerService extends CompanionRemotePeerService { _FakeCompanionRemotePeerService({this.joinGate, this.disconnectGate, this.joinError}); final Completer? joinGate; final Completer? disconnectGate; final Object? joinError; final Completer joinStarted = Completer(); final Completer disconnectStarted = Completer(); final List sentCommands = []; final StreamController _commands = StreamController.broadcast(sync: true); final StreamController _connected = StreamController.broadcast(sync: true); final StreamController _disconnected = StreamController.broadcast(sync: true); final StreamController _errors = StreamController.broadcast(sync: true); final StreamController _statuses = StreamController.broadcast(sync: true); int disconnectCalls = 0; int disposeCalls = 0; bool _streamsClosed = false; bool _serverRunning = false; @override bool get isServerRunning => _serverRunning; bool get hasListeners => _commands.hasListener || _connected.hasListener || _disconnected.hasListener || _errors.hasListener || _statuses.hasListener; @override Stream get onCommandReceived => _commands.stream; @override Stream get onDeviceConnected => _connected.stream; @override Stream get onDeviceDisconnected => _disconnected.stream; @override Stream get onError => _errors.stream; @override Stream get onConnectionStateChanged => _statuses.stream; @override String? get selectedAuthContextId => 'auth-context'; @override String? get selectedHostClientId => 'host-client'; @override Future<({List addresses, int port})> createSessionForContexts( String deviceName, String platform, List authContexts, ) async { _serverRunning = true; return (addresses: const ['127.0.0.1:48634'], port: 48634); } @override Future joinSessionWithContexts( String deviceName, String platform, String hostAddress, List authContexts, { String? authContextId, String expectedHostClientId = '', }) async { if (!joinStarted.isCompleted) joinStarted.complete(); final gate = joinGate; if (gate != null) await gate.future; final error = joinError; if (error != null) throw error; } @override Future joinSessionRacingWithContexts( String deviceName, String platform, List hostAddresses, List authContexts, { String? authContextId, String expectedHostClientId = '', }) async { await joinSessionWithContexts( deviceName, platform, hostAddresses.first, authContexts, authContextId: authContextId, expectedHostClientId: expectedHostClientId, ); return hostAddresses.first; } @override void sendCommand(RemoteCommand command) { sentCommands.add(command); } void emitDeviceDisconnected() { if (!_streamsClosed) _disconnected.add(null); } void emitCommand(RemoteCommand command) { if (!_streamsClosed) _commands.add(command); } void emitError(RemotePeerError error) { if (!_streamsClosed) _errors.add(error); } @override Future disconnect() async { disconnectCalls++; _serverRunning = false; if (!disconnectStarted.isCompleted) disconnectStarted.complete(); final gate = disconnectGate; if (gate != null) await gate.future; } @override Future dispose() async { disposeCalls++; await disconnect(); if (_streamsClosed) return; _streamsClosed = true; await Future.wait([ _commands.close(), _connected.close(), _disconnected.close(), _errors.close(), _statuses.close(), ]); } } class _FakeLanDiscoveryService extends LanDiscoveryService { bool _broadcasting = false; bool _listening = false; int stopBroadcastingCalls = 0; int stopListeningCalls = 0; @override bool get isBroadcasting => _broadcasting; @override bool get isListening => _listening; @override Future startBroadcastingForContexts({ required List contexts, required String deviceName, required String platform, required int wsPort, required List ips, }) async { _broadcasting = true; } @override Future stopBroadcasting() async { stopBroadcastingCalls++; _broadcasting = false; } @override void stopListening() { stopListeningCalls++; _listening = false; } } class _RemoteHarness { _RemoteHarness({required this.provider, required this.database, required this.activeProfile, required this.plexHome}); final CompanionRemoteProvider provider; final AppDatabase database; final ActiveProfileProvider activeProfile; final PlexHomeService plexHome; bool _closed = false; static Future<_RemoteHarness> create( CompanionRemotePeerServiceFactory peerServiceFactory, { LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new, }) async { final database = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(database); final profileConnections = ProfileConnectionRegistry(database); final profiles = ProfileRegistry(database); final storage = await StorageService.getInstance(); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, storage: storage, plexHomeUserFetcher: (_) async => const [], ); final activeProfile = ActiveProfileProvider( registry: profiles, plexHome: plexHome, connections: connections, storage: storage, ); final account = _plexAccount('remote-account', 'remote-client'); final profile = _localProfile('remote-profile'); await connections.upsert(account); await profiles.upsert(profile); await profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'remote-admin'), makeDefault: true, ); await storage.setActiveProfileId(profile.id); await activeProfile.initialize(); final provider = CompanionRemoteProvider.forTesting( peerServiceFactory: peerServiceFactory, discoveryServiceFactory: discoveryServiceFactory, ); final ready = await provider.ensureCryptoReady( _home('remote-admin'), connections: connections, activeProfile: activeProfile, profileConnections: profileConnections, account: account, ); if (!ready) { throw StateError('Remote test harness failed to initialize crypto'); } return _RemoteHarness(provider: provider, database: database, activeProfile: activeProfile, plexHome: plexHome); } Future close() async { if (_closed) return; _closed = true; if (!provider.isDisposed) provider.dispose(); await activeProfile.resetForTesting(); activeProfile.dispose(); await plexHome.dispose(); await database.close(); } }