Files
plezy/test/providers/companion_remote_provider_test.dart
T

565 lines
20 KiB
Dart

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/base_peer_service.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('dispose runs cleanly with no peer service or subscriptions', () {
final p = CompanionRemoteProvider();
expect(p.dispose, returnsNormally);
});
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 — 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();
});
test('connectToManualHost rejects empty host strings via localized crypto guard', () async {
final p = CompanionRemoteProvider();
await expectLater(
() => p.connectToManualHost(''),
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,
);
}