A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.
Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.
The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.
close #1667
366 lines
13 KiB
Dart
366 lines
13 KiB
Dart
import 'package:drift/native.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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/media/ids.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/companion_remote_provider.dart';
|
|
import 'package:plezy/providers/download_provider.dart';
|
|
import 'package:plezy/providers/multi_server_provider.dart';
|
|
import 'package:plezy/providers/playback_state_provider.dart';
|
|
import 'package:plezy/providers/user_profile_provider.dart';
|
|
import 'package:plezy/screens/profile/profile_teardown.dart';
|
|
import 'package:plezy/services/plex_auth_service.dart';
|
|
import 'package:plezy/services/multi_server_manager.dart';
|
|
import 'package:plezy/services/storage_service.dart';
|
|
import 'package:plezy/services/system_shelf_service.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../test_helpers/multi_server_fixtures.dart';
|
|
import '../../test_helpers/prefs.dart';
|
|
|
|
class _PlexHome extends PlexHomeService {
|
|
_PlexHome({required super.connections, required super.profileConnections, required super.storage})
|
|
: super(plexHomeUserFetcher: (_) async => const []);
|
|
|
|
@override
|
|
Future<void> start() async {}
|
|
|
|
@override
|
|
Future<void> reloadFromStorage() async {}
|
|
|
|
@override
|
|
Future<void> dispose() async {}
|
|
}
|
|
|
|
class _Binder implements ActiveProfileBinder {
|
|
_Binder(this.events);
|
|
final List<String> events;
|
|
|
|
@override
|
|
Future<void> rebindActive() async => events.add('rebind');
|
|
|
|
@override
|
|
Future<void> rebindIfActive(String profileId) async => events.add('rebind:$profileId');
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _Downloads extends ChangeNotifier implements DownloadProvider {
|
|
_Downloads(this.events);
|
|
final List<String> events;
|
|
int deleteFailuresRemaining = 1;
|
|
|
|
@override
|
|
Future<void> deleteDownloadsForProfile(String profileId) async {
|
|
events.add('delete-downloads:$profileId');
|
|
if (deleteFailuresRemaining > 0) {
|
|
deleteFailuresRemaining--;
|
|
throw StateError('injected download deletion failure');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> releaseDownloadsForProfileServers(String profileId, Set<String> serverIds) async {
|
|
events.add('release-downloads:$profileId:${serverIds.toList()..sort()}');
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _Companion extends ChangeNotifier implements CompanionRemoteProvider {
|
|
_Companion(this.events);
|
|
final List<String> events;
|
|
|
|
@override
|
|
Future<void> resetForLogout() async {
|
|
events.add('companion-reset');
|
|
throw StateError('stop after first logout mutation');
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _UserProfile extends ChangeNotifier implements UserProfileProvider {
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _Playback extends ChangeNotifier implements PlaybackStateProvider {
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
const channel = MethodChannel('test/profile_teardown_shelf');
|
|
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
|
|
setUp(() {
|
|
resetSharedPreferencesForTest();
|
|
});
|
|
|
|
tearDown(() {
|
|
messenger.setMockMethodCallHandler(channel, null);
|
|
SystemShelfService.debugOverrideInstance(null);
|
|
});
|
|
|
|
testWidgets('active deletion clears shelf before its first destructive mutation', (tester) async {
|
|
final events = <String>[];
|
|
final harness = await _pumpHarness(tester, events: events, channel: channel);
|
|
addTearDown(harness.dispose);
|
|
final target = harness.active.active!;
|
|
|
|
await expectLater(deleteProfile(harness.context, target), throwsStateError);
|
|
|
|
expect(events.take(2), ['clear:${target.id}', 'delete-downloads:${target.id}']);
|
|
expect(events, contains('rebind:${target.id}'));
|
|
});
|
|
|
|
testWidgets('inactive deletion does not clear the active owner', (tester) async {
|
|
final events = <String>[];
|
|
final harness = await _pumpHarness(tester, events: events, channel: channel);
|
|
addTearDown(harness.dispose);
|
|
final inactive = Profile.local(id: 'inactive', displayName: 'Inactive', createdAt: DateTime(2026, 1, 2));
|
|
await harness.profileRegistry.upsert(inactive);
|
|
|
|
await expectLater(deleteProfile(harness.context, inactive), throwsStateError);
|
|
|
|
expect(events, ['delete-downloads:inactive']);
|
|
});
|
|
|
|
testWidgets('Plex sign-out keeps account and joins when download deletion fails, then retry completes', (
|
|
tester,
|
|
) async {
|
|
final events = <String>[];
|
|
final harness = await _pumpHarness(tester, events: events, channel: channel);
|
|
addTearDown(harness.dispose);
|
|
const homeUserUuid = 'aaaaaaaaaaaaaaaa';
|
|
final account = _plexAccount();
|
|
final virtualProfileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: homeUserUuid);
|
|
await harness.connections.upsert(account);
|
|
await harness.profileConnections.upsert(
|
|
ProfileConnection(
|
|
profileId: virtualProfileId,
|
|
connectionId: account.id,
|
|
userToken: 'virtual-token',
|
|
userIdentifier: homeUserUuid,
|
|
),
|
|
);
|
|
await harness.profileConnections.upsert(
|
|
ProfileConnection(
|
|
profileId: 'active',
|
|
connectionId: account.id,
|
|
userToken: 'borrower-token',
|
|
userIdentifier: homeUserUuid,
|
|
),
|
|
);
|
|
await harness.database.insertSyncRule(
|
|
profileId: virtualProfileId,
|
|
serverId: ServerId('plex-machine'),
|
|
ratingKey: 'show-1',
|
|
globalKey: 'plex-machine:show-1',
|
|
targetType: 'show',
|
|
episodeCount: 1,
|
|
);
|
|
await harness.database.insertWatchAction(
|
|
profileId: virtualProfileId,
|
|
serverId: ServerId('plex-machine'),
|
|
ratingKey: 'episode-1',
|
|
actionType: 'watched',
|
|
);
|
|
|
|
final failedSignOut = confirmAndSignOutPlexAccount(harness.context, accountConnectionId: account.id);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byType(FilledButton));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(await failedSignOut, isFalse);
|
|
expect(await harness.connections.get(account.id), isNotNull);
|
|
expect((await harness.profileConnections.listForConnection(account.id)).map((row) => row.profileId).toSet(), {
|
|
'active',
|
|
virtualProfileId,
|
|
});
|
|
expect(await harness.database.getSyncRules(profileId: virtualProfileId), hasLength(1));
|
|
expect(await harness.database.getPendingSyncCount(profileId: virtualProfileId), 1);
|
|
|
|
final retry = confirmAndSignOutPlexAccount(harness.context, accountConnectionId: account.id);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byType(FilledButton));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(await retry, isTrue);
|
|
expect(await harness.connections.get(account.id), isNull);
|
|
expect(await harness.profileConnections.listForConnection(account.id), isEmpty);
|
|
expect(await harness.database.getSyncRules(profileId: virtualProfileId), isEmpty);
|
|
expect(await harness.database.getPendingSyncCount(profileId: virtualProfileId), 0);
|
|
expect(events.where((event) => event == 'delete-downloads:$virtualProfileId'), hasLength(2));
|
|
expect(events, contains('release-downloads:active:[plex-machine]'));
|
|
});
|
|
|
|
testWidgets('full logout clears shelf before companion, identity, or credential teardown', (tester) async {
|
|
final events = <String>[];
|
|
final harness = await _pumpHarness(tester, events: events, channel: channel);
|
|
addTearDown(harness.dispose);
|
|
|
|
await expectLater(logoutAllProfiles(harness.context), throwsStateError);
|
|
|
|
expect(events.take(2), ['clear:active', 'companion-reset']);
|
|
});
|
|
}
|
|
|
|
class _Harness {
|
|
_Harness({
|
|
required this.context,
|
|
required this.active,
|
|
required this.profileRegistry,
|
|
required this.multiServer,
|
|
required this.manager,
|
|
required this.plexHome,
|
|
required this.database,
|
|
required this.connections,
|
|
required this.profileConnections,
|
|
});
|
|
|
|
final BuildContext context;
|
|
final ActiveProfileProvider active;
|
|
final ProfileRegistry profileRegistry;
|
|
final MultiServerProvider multiServer;
|
|
final MultiServerManager manager;
|
|
final PlexHomeService plexHome;
|
|
final AppDatabase database;
|
|
final ConnectionRegistry connections;
|
|
final ProfileConnectionRegistry profileConnections;
|
|
|
|
Future<void> dispose() async {
|
|
active.dispose();
|
|
multiServer.dispose();
|
|
manager.dispose();
|
|
await plexHome.dispose();
|
|
await database.close();
|
|
}
|
|
}
|
|
|
|
Future<_Harness> _pumpHarness(
|
|
WidgetTester tester, {
|
|
required List<String> events,
|
|
required MethodChannel channel,
|
|
}) async {
|
|
final database = AppDatabase.forTesting(NativeDatabase.memory());
|
|
final profileRegistry = ProfileRegistry(database);
|
|
final connections = ConnectionRegistry(database);
|
|
final profileConnections = ProfileConnectionRegistry(database);
|
|
final storage = await StorageService.getInstance();
|
|
final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage);
|
|
final active = ActiveProfileProvider(
|
|
registry: profileRegistry,
|
|
plexHome: plexHome,
|
|
connections: connections,
|
|
profileConnections: profileConnections,
|
|
storage: storage,
|
|
);
|
|
final profile = Profile.local(id: 'active', displayName: 'Active', createdAt: DateTime(2026, 1, 1));
|
|
await profileRegistry.upsert(profile);
|
|
await storage.setActiveProfileId(profile.id);
|
|
await active.initialize();
|
|
final manager = MultiServerManager();
|
|
final multiServer = testMultiServerProvider(manager);
|
|
final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
|
|
shelf.beginProfileSession(profile.id);
|
|
SystemShelfService.debugOverrideInstance(shelf);
|
|
messengerFor(channel).setMockMethodCallHandler(channel, (call) async {
|
|
if (call.method == 'clear') {
|
|
events.add('clear:${(call.arguments as Map)['ownerId']}');
|
|
}
|
|
return true;
|
|
});
|
|
|
|
BuildContext? captured;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
Provider<StorageService>.value(value: storage),
|
|
Provider<AppDatabase>.value(value: database),
|
|
Provider<ProfileRegistry>.value(value: profileRegistry),
|
|
Provider<ConnectionRegistry>.value(value: connections),
|
|
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
|
|
Provider<PlexHomeService>.value(value: plexHome),
|
|
ChangeNotifierProvider<ActiveProfileProvider>.value(value: active),
|
|
Provider<ActiveProfileBinder>.value(value: _Binder(events)),
|
|
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
|
|
ChangeNotifierProvider<DownloadProvider>.value(value: _Downloads(events)),
|
|
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: _Companion(events)),
|
|
ChangeNotifierProvider<UserProfileProvider>.value(value: _UserProfile()),
|
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: _Playback()),
|
|
],
|
|
child: MaterialApp(
|
|
home: Scaffold(
|
|
body: Builder(
|
|
builder: (context) {
|
|
captured = context;
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
return _Harness(
|
|
context: captured!,
|
|
active: active,
|
|
profileRegistry: profileRegistry,
|
|
multiServer: multiServer,
|
|
manager: manager,
|
|
plexHome: plexHome,
|
|
database: database,
|
|
connections: connections,
|
|
profileConnections: profileConnections,
|
|
);
|
|
}
|
|
|
|
TestDefaultBinaryMessenger messengerFor(MethodChannel channel) =>
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
|
|
PlexAccountConnection _plexAccount() {
|
|
return PlexAccountConnection(
|
|
id: 'plex-account',
|
|
accountToken: 'account-token',
|
|
clientIdentifier: 'client-1',
|
|
accountLabel: 'Plex',
|
|
servers: [
|
|
PlexServer(
|
|
name: 'Plex Server',
|
|
clientIdentifier: 'plex-machine',
|
|
accessToken: 'server-token',
|
|
connections: [
|
|
PlexConnection(
|
|
protocol: 'https',
|
|
address: 'plex.example.test',
|
|
port: 443,
|
|
uri: 'https://plex.example.test',
|
|
local: false,
|
|
relay: false,
|
|
ipv6: false,
|
|
),
|
|
],
|
|
owned: true,
|
|
),
|
|
],
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000),
|
|
lastAuthenticatedAt: DateTime.fromMillisecondsSinceEpoch(1_000_000),
|
|
);
|
|
}
|