306 lines
11 KiB
Dart
306 lines
11 KiB
Dart
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:plezy/connection/connection.dart';
|
|
import 'package:plezy/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/data_aggregation_service.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/prefs.dart';
|
|
|
|
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<PlexHomeUser> 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,
|
|
storage: storage,
|
|
);
|
|
manager = MultiServerManager();
|
|
multiServerProvider = MultiServerProvider(manager, DataAggregationService(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<Profile> 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<void>.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(activeProfile.isBinding, isFalse);
|
|
expect(activeProfile.lastBindingSucceeded, isTrue);
|
|
expect(binder.debugLastBoundProfileId, profile.id);
|
|
expect(notifications, lessThan(8));
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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,
|
|
}) async {
|
|
binder.dispose();
|
|
multiServerProvider.dispose();
|
|
|
|
final capturingManager = _CapturingMultiServerManager();
|
|
manager = capturingManager;
|
|
multiServerProvider = MultiServerProvider(manager, DataAggregationService(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: 'account-server-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');
|
|
});
|
|
|
|
test('does not use cached server metadata after cached token auth failure', () async {
|
|
final prepared = await preparePlexHomeBind(
|
|
protected: true,
|
|
httpClient: MockClient((request) async {
|
|
return http.Response('{"errors":[]}', 401, headers: {'content-type': 'application/json'});
|
|
}),
|
|
);
|
|
|
|
await binder.rebindActive();
|
|
|
|
expect(activeProfile.lastBindingSucceeded, isFalse);
|
|
expect(prepared.manager.refreshCalls, 0);
|
|
final pc = await profileConnections.get(prepared.profileId, 'plex.account');
|
|
expect(pc?.userToken, isNull);
|
|
});
|
|
});
|
|
}
|
|
|
|
PlexServer _server({required String accessToken}) {
|
|
return PlexServer(
|
|
name: 'Home Server',
|
|
clientIdentifier: 'srv-1',
|
|
accessToken: accessToken,
|
|
connections: [
|
|
PlexConnection(
|
|
protocol: 'https',
|
|
address: '192.168.1.3',
|
|
port: 32400,
|
|
uri: 'https://192-168-1-3.machine.plex.direct:32400',
|
|
local: true,
|
|
relay: false,
|
|
ipv6: false,
|
|
),
|
|
],
|
|
owned: true,
|
|
presence: true,
|
|
);
|
|
}
|
|
|
|
class _CapturingMultiServerManager extends MultiServerManager {
|
|
int refreshCalls = 0;
|
|
PlexAccountConnection? lastConnection;
|
|
|
|
@override
|
|
Future<Set<String>> refreshTokensForProfile(
|
|
PlexAccountConnection connection, {
|
|
Duration timeout = MediaServerTimeouts.perServerConnect,
|
|
}) async {
|
|
refreshCalls++;
|
|
lastConnection = connection;
|
|
return connection.servers.map((server) => server.clientIdentifier).toSet();
|
|
}
|
|
}
|