Files
plezy/test/providers/companion_remote_provider_test.dart
T

1133 lines
40 KiB
Dart

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<void>.delayed(Duration.zero);
});
});
group('CompanionRemoteProvider — reconnect ownership', () {
test('newest overlapping connect owns the session and disposes the stale candidate once', () async {
final firstJoinGate = Completer<void>();
final firstDisposalGate = Completer<void>();
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<void>.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<void>();
final predecessor = _FakeCompanionRemotePeerService(disconnectGate: predecessorDisposalGate);
final replacement = _FakeCompanionRemotePeerService();
final factory = _FakePeerFactory([predecessor, replacement]);
final harness = await _RemoteHarness.create(factory.call);
addTearDown(harness.close);
Future<void>? 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 = <RemoteSessionStatus>[];
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<void>();
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<void>();
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 = <RemoteSessionStatus>[];
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<void>.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<void>();
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<void>();
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<void>();
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<void>.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<void>();
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<PeerError>().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<String> 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<void>? joinGate;
final Completer<void>? disconnectGate;
final Object? joinError;
final Completer<void> joinStarted = Completer<void>();
final Completer<void> disconnectStarted = Completer<void>();
final List<RemoteCommand> sentCommands = [];
final StreamController<RemoteCommand> _commands = StreamController<RemoteCommand>.broadcast(sync: true);
final StreamController<RemoteDevice> _connected = StreamController<RemoteDevice>.broadcast(sync: true);
final StreamController<void> _disconnected = StreamController<void>.broadcast(sync: true);
final StreamController<RemotePeerError> _errors = StreamController<RemotePeerError>.broadcast(sync: true);
final StreamController<RemoteSessionStatus> _statuses = StreamController<RemoteSessionStatus>.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<RemoteCommand> get onCommandReceived => _commands.stream;
@override
Stream<RemoteDevice> get onDeviceConnected => _connected.stream;
@override
Stream<void> get onDeviceDisconnected => _disconnected.stream;
@override
Stream<RemotePeerError> get onError => _errors.stream;
@override
Stream<RemoteSessionStatus> get onConnectionStateChanged => _statuses.stream;
@override
String? get selectedAuthContextId => 'auth-context';
@override
String? get selectedHostClientId => 'host-client';
@override
Future<({List<String> addresses, int port})> createSessionForContexts(
String deviceName,
String platform,
List<RemoteAuthContext> authContexts,
) async {
_serverRunning = true;
return (addresses: const ['127.0.0.1:48634'], port: 48634);
}
@override
Future<void> joinSessionWithContexts(
String deviceName,
String platform,
String hostAddress,
List<RemoteAuthContext> 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<String> joinSessionRacingWithContexts(
String deviceName,
String platform,
List<String> hostAddresses,
List<RemoteAuthContext> 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<void> disconnect() async {
disconnectCalls++;
_serverRunning = false;
if (!disconnectStarted.isCompleted) disconnectStarted.complete();
final gate = disconnectGate;
if (gate != null) await gate.future;
}
@override
Future<void> 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<void> startBroadcastingForContexts({
required List<RemoteAuthContext> contexts,
required String deviceName,
required String platform,
required int wsPort,
required List<String> ips,
}) async {
_broadcasting = true;
}
@override
Future<void> 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<void> close() async {
if (_closed) return;
_closed = true;
if (!provider.isDisposed) provider.dispose();
await activeProfile.resetForTesting();
activeProfile.dispose();
await plexHome.dispose();
await database.close();
}
}