fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
@@ -415,6 +415,7 @@ void main() {
expect(prepared.manager.refreshCalls, 1);
expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token');
expect(prepared.manager.lastConnection?.servers.single.clientIdentifier, 'srv-1');
expect(prepared.manager.lastProfileId, prepared.profileId);
});
test('binds from cache when plex.tv rejects the token, then flags re-auth from the reconcile', () async {
@@ -433,6 +434,7 @@ void main() {
expect(activeProfile.lastBindingSucceeded, isTrue);
expect(prepared.manager.refreshCalls, 1);
expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token');
expect(prepared.manager.lastProfileId, prepared.profileId);
// The background reconcile sees the 401: wipes the cached token and
// flags the account for re-auth — no silent /switch re-mint that
@@ -470,6 +472,7 @@ void main() {
// per-server tokens in place.
await pumpUntil(() async => prepared.manager.refreshCalls == 2);
expect(prepared.manager.lastConnection?.servers.single.accessToken, 'server-token');
expect(prepared.manager.lastProfileId, prepared.profileId);
// And the refreshed metadata was persisted onto the stored account row.
final account = await connections.getPlexAccount('plex.account');
@@ -1095,14 +1098,17 @@ class _CountingFailingJellyfinManager extends MultiServerManager {
class _CapturingMultiServerManager extends MultiServerManager {
int refreshCalls = 0;
PlexAccountConnection? lastConnection;
String? lastProfileId;
@override
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
refreshCalls++;
lastConnection = connection;
lastProfileId = profileId;
return connection.servers.map((server) => server.clientIdentifier).toSet();
}
}
@@ -1113,6 +1119,7 @@ class _FailingPlexMultiServerManager extends MultiServerManager {
@override
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
refreshCalls++;
@@ -1129,6 +1136,7 @@ class _RecordingPlexManager extends MultiServerManager {
@override
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
calls++;
@@ -1148,6 +1156,7 @@ class _BlockingMixedMultiServerManager extends MultiServerManager {
@override
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
if (!plexStarted.isCompleted) plexStarted.complete();
@@ -12,9 +12,72 @@ import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../test_helpers/prefs.dart';
final class _RecordingPreferencesPlatform extends SharedPreferencesAsyncPlatform {
_RecordingPreferencesPlatform(this.delegate);
final SharedPreferencesAsyncPlatform delegate;
final List<String> writes = [];
String? failIntKey;
@override
Future<void> setString(String key, String value, SharedPreferencesOptions options) async {
writes.add('string:$key');
await delegate.setString(key, value, options);
}
@override
Future<void> setInt(String key, int value, SharedPreferencesOptions options) async {
writes.add('int:$key');
if (key == failIntKey) throw StateError('injected recency failure');
await delegate.setInt(key, value, options);
}
@override
Future<void> setBool(String key, bool value, SharedPreferencesOptions options) =>
delegate.setBool(key, value, options);
@override
Future<void> setDouble(String key, double value, SharedPreferencesOptions options) =>
delegate.setDouble(key, value, options);
@override
Future<void> setStringList(String key, List<String> value, SharedPreferencesOptions options) =>
delegate.setStringList(key, value, options);
@override
Future<String?> getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options);
@override
Future<bool?> getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options);
@override
Future<double?> getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options);
@override
Future<int?> getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options);
@override
Future<List<String>?> getStringList(String key, SharedPreferencesOptions options) =>
delegate.getStringList(key, options);
@override
Future<void> clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.clear(parameters, options);
@override
Future<Map<String, Object>> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.getPreferences(parameters, options);
@override
Future<Set<String>> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.getKeys(parameters, options);
}
PlexHomeUser _homeUser(String uuid, {String name = 'Home User'}) {
return PlexHomeUser(
id: 1,
@@ -47,10 +110,13 @@ void main() {
late PlexHomeService plexHome;
late ActiveProfileProvider provider;
late StorageService storage;
late _RecordingPreferencesPlatform preferencesPlatform;
late List<PlexHomeUser> fetchedHomeUsers;
setUp(() async {
resetSharedPreferencesForTest();
preferencesPlatform = _RecordingPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!);
SharedPreferencesAsyncPlatform.instance = preferencesPlatform;
db = AppDatabase.forTesting(NativeDatabase.memory());
registry = ProfileRegistry(db);
connections = ConnectionRegistry(db);
@@ -171,6 +237,51 @@ void main() {
expect(provider.activeId, 'p2');
});
test('recency failure leaves stored and in-memory active identity unchanged', () async {
await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)));
await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)));
await storage.setActiveProfileId('p1');
await provider.initialize();
preferencesPlatform.writes.clear();
preferencesPlatform.failIntKey = 'profile_last_used_p2';
final p2 = provider.profiles.firstWhere((profile) => profile.id == 'p2');
await expectLater(provider.activate(p2), throwsA(isA<StateError>()));
await storage.prefs.reloadCache();
expect(preferencesPlatform.writes, ['int:profile_last_used_p2']);
expect(storage.getProfileLastUsed('p2'), isNull);
expect(storage.getActiveProfileId(), 'p1');
expect(provider.activeId, 'p1');
});
test('activation persists recency then marker before notifying listeners', () async {
await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)));
await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)));
await storage.setActiveProfileId('p1');
await provider.initialize();
preferencesPlatform.writes.clear();
var notifiedAfterCommit = false;
void listener() {
if (provider.activeId != 'p2') return;
notifiedAfterCommit =
storage.getProfileLastUsed('p2') != null &&
storage.getActiveProfileId() == 'p2' &&
preferencesPlatform.writes.length >= 2 &&
preferencesPlatform.writes[0] == 'int:profile_last_used_p2' &&
preferencesPlatform.writes[1] == 'string:active_app_profile_id';
}
provider.addListener(listener);
addTearDown(() => provider.removeListener(listener));
final p2 = provider.profiles.firstWhere((profile) => profile.id == 'p2');
expect(await provider.activate(p2), isTrue);
expect(preferencesPlatform.writes.take(2), ['int:profile_last_used_p2', 'string:active_app_profile_id']);
expect(notifiedAfterCommit, isTrue);
});
test('activate moves the selected profile to the front by recent usage', () async {
await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)));
await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)));
+465
View File
@@ -6,9 +6,12 @@ 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/plex_home_cache_codec.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../test_helpers/prefs.dart';
@@ -40,15 +43,130 @@ PlexAccountConnection _account(String id) {
);
}
class _QueuedFetcher {
final requests = <({String token, Completer<List<PlexHomeUser>> result})>[];
final _requested = StreamController<void>.broadcast(sync: true);
Future<List<PlexHomeUser>> call(String token) {
final result = Completer<List<PlexHomeUser>>();
requests.add((token: token, result: result));
_requested.add(null);
return result.future;
}
Future<void> waitForCount(int count) async {
if (requests.length >= count) return;
await _requested.stream.firstWhere((_) => requests.length >= count);
}
Future<void> close() async {
for (final request in requests) {
if (!request.result.isCompleted) request.result.complete(const []);
}
await _requested.close();
}
}
final class _BlockingPreferencesPlatform extends SharedPreferencesAsyncPlatform {
_BlockingPreferencesPlatform(this.delegate);
final SharedPreferencesAsyncPlatform delegate;
String? _blockedStringKey;
Completer<void>? _writeStarted;
Completer<void>? _releaseWrite;
String? _failedStringKey;
final Map<String, int> stringWriteAttempts = {};
void blockNextStringWrite(String key) {
_blockedStringKey = key;
_writeStarted = Completer<void>();
_releaseWrite = Completer<void>();
}
void failNextStringWrite(String key) {
_failedStringKey = key;
}
Future<void> get writeStarted => _writeStarted!.future;
void releaseBlockedWrite() {
final release = _releaseWrite;
if (release != null && !release.isCompleted) release.complete();
}
Future<String?> persistedString(String key) => delegate.getString(key, const SharedPreferencesOptions());
@override
Future<void> setString(String key, String value, SharedPreferencesOptions options) async {
stringWriteAttempts[key] = (stringWriteAttempts[key] ?? 0) + 1;
if (key == _blockedStringKey) {
_blockedStringKey = null;
_writeStarted!.complete();
await _releaseWrite!.future;
}
if (key == _failedStringKey) {
_failedStringKey = null;
throw StateError('injected string persistence failure');
}
await delegate.setString(key, value, options);
}
@override
Future<void> setInt(String key, int value, SharedPreferencesOptions options) => delegate.setInt(key, value, options);
@override
Future<void> setBool(String key, bool value, SharedPreferencesOptions options) =>
delegate.setBool(key, value, options);
@override
Future<void> setDouble(String key, double value, SharedPreferencesOptions options) =>
delegate.setDouble(key, value, options);
@override
Future<void> setStringList(String key, List<String> value, SharedPreferencesOptions options) =>
delegate.setStringList(key, value, options);
@override
Future<String?> getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options);
@override
Future<bool?> getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options);
@override
Future<double?> getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options);
@override
Future<int?> getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options);
@override
Future<List<String>?> getStringList(String key, SharedPreferencesOptions options) =>
delegate.getStringList(key, options);
@override
Future<void> clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.clear(parameters, options);
@override
Future<Map<String, Object>> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.getPreferences(parameters, options);
@override
Future<Set<String>> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
delegate.getKeys(parameters, options);
}
void main() {
late AppDatabase db;
late ConnectionRegistry connections;
late ProfileConnectionRegistry profileConnections;
late StorageService storage;
late _BlockingPreferencesPlatform preferencesPlatform;
late PlexHomeService service;
setUp(() async {
resetSharedPreferencesForTest();
preferencesPlatform = _BlockingPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!);
SharedPreferencesAsyncPlatform.instance = preferencesPlatform;
db = AppDatabase.forTesting(NativeDatabase.memory());
connections = ConnectionRegistry(db);
profileConnections = ProfileConnectionRegistry(db);
@@ -254,5 +372,352 @@ void main() {
expect(service.current, isEmpty);
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
});
test('later explicit refresh wins regardless of completion order', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.ordered');
await connections.upsert(acct);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
final earlier = service.refresh(acct);
await fetcher.waitForCount(1);
final later = service.refresh(acct);
await fetcher.waitForCount(2);
fetcher.requests[1].result.complete([_user('new-membership')]);
expect(await later, isTrue);
fetcher.requests[0].result.complete([_user('stale-membership')]);
expect(await earlier, isFalse);
await Future<void>.delayed(Duration.zero);
expect(service.current[acct.id]!.single.uuid, 'new-membership');
final persisted = decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!);
expect(persisted.single.uuid, 'new-membership');
expect(emissions, hasLength(2));
expect(emissions.last[acct.id]!.single.uuid, 'new-membership');
expect(
emissions.where((snapshot) => snapshot[acct.id]?.any((user) => user.uuid == 'stale-membership') ?? false),
isEmpty,
);
});
test('identical newer refresh waits for a superseded cache write to settle', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.commit-race');
final cacheKey = 'plex_home_users_${acct.id}';
await connections.upsert(acct);
final seed = service.refresh(acct);
await fetcher.waitForCount(1);
fetcher.requests[0].result.complete([_user('baseline-membership')]);
expect(await seed, isTrue);
preferencesPlatform.blockNextStringWrite(cacheKey);
addTearDown(preferencesPlatform.releaseBlockedWrite);
final superseded = service.refresh(acct);
await fetcher.waitForCount(2);
fetcher.requests[1].result.complete([_user('new-membership')]);
await preferencesPlatform.writeStarted;
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'new-membership');
expect(
decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid,
'baseline-membership',
);
expect(service.current[acct.id]!.single.uuid, 'baseline-membership');
final newerSettled = Completer<bool>();
final newer = service.refresh(acct);
unawaited(newer.then(newerSettled.complete));
await fetcher.waitForCount(3);
fetcher.requests[2].result.complete([_user('new-membership')]);
await Future<void>.delayed(Duration.zero);
expect(newerSettled.isCompleted, isFalse);
expect(service.current[acct.id]!.single.uuid, 'baseline-membership');
preferencesPlatform.releaseBlockedWrite();
expect(await superseded, isFalse);
expect(await newer, isTrue);
expect(service.current[acct.id]!.single.uuid, 'new-membership');
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'new-membership');
expect(
decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid,
'new-membership',
);
});
test('identical payload retries after a transient cache persistence failure', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.persistence-retry');
final cacheKey = 'plex_home_users_${acct.id}';
await connections.upsert(acct);
final seed = service.refresh(acct);
await fetcher.waitForCount(1);
fetcher.requests[0].result.complete([_user('baseline-membership')]);
expect(await seed, isTrue);
preferencesPlatform.failNextStringWrite(cacheKey);
final failed = service.refresh(acct);
await fetcher.waitForCount(2);
fetcher.requests[1].result.complete([_user('new-membership')]);
expect(await failed, isFalse);
expect(service.current[acct.id]!.single.uuid, 'baseline-membership');
expect(
decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid,
'baseline-membership',
);
final attemptsAfterFailure = preferencesPlatform.stringWriteAttempts[cacheKey]!;
final retry = service.refresh(acct);
await fetcher.waitForCount(3);
fetcher.requests[2].result.complete([_user('new-membership')]);
expect(await retry, isTrue);
expect(preferencesPlatform.stringWriteAttempts[cacheKey], attemptsAfterFailure + 1);
expect(service.current[acct.id]!.single.uuid, 'new-membership');
expect(
decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid,
'new-membership',
);
});
test('startup background work coalesces with an active public refresh', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.coalesced');
await connections.upsert(acct);
final explicit = service.refresh(acct);
await fetcher.waitForCount(1);
await service.start();
await pumpEventQueue();
expect(fetcher.requests, hasLength(1));
fetcher.requests.single.result.complete([_user('authoritative')]);
expect(await explicit, isTrue);
await pumpEventQueue();
expect(fetcher.requests, hasLength(1));
expect(service.current[acct.id]!.single.uuid, 'authoritative');
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'authoritative');
});
test('overlapping refreshes remain isolated by account', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final firstAccount = _account('plex.first');
final secondAccount = _account('plex.second');
await connections.upsert(firstAccount);
await connections.upsert(secondAccount);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
final firstRefresh = service.refresh(firstAccount);
final secondRefresh = service.refresh(secondAccount);
await fetcher.waitForCount(2);
expect(fetcher.requests[0].token, firstAccount.accountToken);
expect(fetcher.requests[1].token, secondAccount.accountToken);
fetcher.requests[1].result.complete([_user('second-user')]);
expect(await secondRefresh, isTrue);
fetcher.requests[0].result.complete([_user('first-user')]);
expect(await firstRefresh, isTrue);
await Future<void>.delayed(Duration.zero);
expect(service.current[firstAccount.id]!.single.uuid, 'first-user');
expect(service.current[secondAccount.id]!.single.uuid, 'second-user');
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(firstAccount.id)!).single.uuid, 'first-user');
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(secondAccount.id)!).single.uuid, 'second-user');
expect(
emissions,
contains(
predicate<Map<String, List<PlexHomeUser>>>(
(snapshot) => snapshot.containsKey(firstAccount.id) && snapshot.containsKey(secondAccount.id),
),
),
);
});
test('connection removal invalidates a blocked refresh before clearing cache', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
final acct = _account('plex.removed-late');
await connections.upsert(acct);
await storage.savePlexHomeUsersCache(acct.id, [_user('cached-before-removal').toJson()]);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
await service.start();
await fetcher.waitForCount(1);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
final removedSnapshot = service.stream.firstWhere((snapshot) => !snapshot.containsKey(acct.id));
await connections.remove(acct.id);
await removedSnapshot;
fetcher.requests.single.result.complete([_user('late-user')]);
await pumpEventQueue();
expect(service.current, isNot(contains(acct.id)));
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
expect(emissions.where((snapshot) => !snapshot.containsKey(acct.id)), hasLength(1));
});
test('remove then re-add during a blocked refresh keeps the replacement cache', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
final original = _account('plex.readded');
await connections.upsert(original);
await storage.savePlexHomeUsersCache(original.id, [_user('cached-before-removal').toJson()]);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
await service.start();
await fetcher.waitForCount(1);
await connections.remove(original.id);
await pumpEventQueue();
final replacement = PlexAccountConnection(
id: original.id,
accountToken: 'replacement-token',
clientIdentifier: original.clientIdentifier,
accountLabel: original.accountLabel,
createdAt: original.createdAt,
);
await connections.upsert(replacement);
fetcher.requests.first.result.complete([_user('stale-user')]);
await fetcher.waitForCount(2);
expect(fetcher.requests[1].token, 'replacement-token');
fetcher.requests[1].result.complete([_user('replacement-user')]);
await pumpEventQueue();
expect(service.current[original.id]!.single.uuid, 'replacement-user');
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(original.id)!).single.uuid, 'replacement-user');
});
test('clearAll invalidates a blocked refresh without a late emission', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.cleared-late');
await connections.upsert(acct);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
final refresh = service.refresh(acct);
await fetcher.waitForCount(1);
await service.clearAll();
fetcher.requests.single.result.complete([_user('late-user')]);
expect(await refresh, isFalse);
await Future<void>.delayed(Duration.zero);
expect(service.current, isEmpty);
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
expect(emissions, hasLength(2));
expect(emissions.every((snapshot) => !snapshot.containsKey(acct.id)), isTrue);
});
test('dispose invalidates a blocked refresh without restoring state', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.disposed-late');
await connections.upsert(acct);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
final refresh = service.refresh(acct);
await fetcher.waitForCount(1);
await service.dispose();
fetcher.requests.single.result.complete([_user('late-user')]);
expect(await refresh, isFalse);
await Future<void>.delayed(Duration.zero);
expect(service.current, isEmpty);
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
expect(emissions.every((snapshot) => !snapshot.containsKey(acct.id)), isTrue);
});
test('failed refresh preserves the completed cache without another emission', () async {
final fetcher = _QueuedFetcher();
addTearDown(fetcher.close);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: fetcher.call,
);
final acct = _account('plex.failure-cache');
await connections.upsert(acct);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
final seededRefresh = service.refresh(acct);
await fetcher.waitForCount(1);
fetcher.requests[0].result.complete([_user('preserved-user')]);
expect(await seededRefresh, isTrue);
final failedRefresh = service.refresh(acct);
await fetcher.waitForCount(2);
fetcher.requests[1].result.completeError(StateError('synthetic fetch failure'));
expect(await failedRefresh, isFalse);
await Future<void>.delayed(Duration.zero);
expect(service.current[acct.id]!.single.uuid, 'preserved-user');
expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'preserved-user');
expect(emissions, hasLength(2));
});
});
}
+688
View File
@@ -0,0 +1,688 @@
import 'dart:async';
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_registry.dart';
import 'package:plezy/database/app_database.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_activation.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/system_shelf_service.dart';
import 'package:provider/provider.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
void markUserInitiatedActivation(String profileId) => events.add('mark:$profileId');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RollbackBinder implements ActiveProfileBinder {
_RollbackBinder(this.active, this.events, {this.targetBindingRelease}) : boundClientProfileId = active.activeId {
active.addListener(_handleActiveChanged);
}
final ActiveProfileProvider active;
final List<String> events;
final Completer<void> rebindStarted = Completer<void>();
final Completer<void> allowRebind = Completer<void>();
final Completer<void> targetBindingStarted = Completer<void>();
final Completer<void>? targetBindingRelease;
String? boundClientProfileId;
bool _targetFailed = false;
final Set<String> _successfullyBoundProfileIds = {};
@override
void markUserInitiatedActivation(String profileId) {
events.add('mark:$profileId');
}
void _handleActiveChanged() {
if (active.activeId == 'target' && !_targetFailed) {
_targetFailed = true;
boundClientProfileId = null;
SystemShelfService().beginProfileSession('target');
active.markBindingStarted();
targetBindingStarted.complete();
final release = targetBindingRelease;
if (release == null) {
scheduleMicrotask(() => active.markBindingFinished(success: false));
} else {
unawaited(
release.future.then((_) {
if (active.activeId == 'target') {
active.markBindingFinished(success: false);
}
}),
);
}
return;
}
final profileId = active.activeId;
if ((profileId == 'newer' || profileId == 'latest') && _successfullyBoundProfileIds.add(profileId!)) {
boundClientProfileId = profileId;
SystemShelfService().beginProfileSession(profileId);
active.markBindingStarted();
scheduleMicrotask(() => active.markBindingFinished(success: true));
}
}
@override
Future<void> rebindActive() async {
events.add('rebind:${active.activeId}');
active.markBindingStarted();
rebindStarted.complete();
await allowRebind.future;
boundClientProfileId = active.activeId;
active.markBindingFinished(success: true);
}
@override
void dispose() {
active.removeListener(_handleActiveChanged);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ThrowingActiveProfileProvider extends ActiveProfileProvider {
_ThrowingActiveProfileProvider({
required super.registry,
required super.plexHome,
required super.connections,
required super.storage,
super.activeProfileIdWriter,
});
bool throwOnActivation = false;
@override
Future<bool> activate(Profile profile, {String? pin}) {
if (throwOnActivation) throw StateError('synthetic activation failure');
return super.activate(profile, pin: pin);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/profile_activation_shelf');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
setUp(resetSharedPreferencesForTest);
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
SystemShelfService.debugOverrideInstance(null);
});
testWidgets('successful different-profile activation clears old owner before identity publication', (tester) async {
final harness = await _pumpHarness(tester, channel);
addTearDown(harness.dispose);
final events = harness.events;
harness.active.addListener(() => events.add('active:${harness.active.activeId}'));
final activated = await switchProfileFromUi(harness.context, harness.target);
expect(activated, isTrue);
expect(events, ['clear:owner', 'mark:target', 'active:target']);
expect(SystemShelfService().debugActiveOwner, isNull);
});
testWidgets('newer switch overtakes an older switch blocked clearing the same shelf owner', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true);
addTearDown(harness.dispose);
final ownerClearStarted = Completer<void>();
final allowOwnerClear = Completer<void>();
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'clear') {
final ownerId = (call.arguments as Map)['ownerId'];
harness.events.add('clear:$ownerId');
if (ownerId == 'owner') {
ownerClearStarted.complete();
await allowOwnerClear.future;
}
}
return true;
});
final olderSwitch = switchProfileFromUi(harness.context, harness.target);
await ownerClearStarted.future;
expect(await switchProfileFromUi(harness.context, harness.newer), isTrue);
expect(harness.active.activeId, 'newer');
expect(harness.rollbackBinder!.boundClientProfileId, 'newer');
expect(harness.events, contains('mark:newer'));
expect(harness.events, isNot(contains('mark:target')));
allowOwnerClear.complete();
expect(await olderSwitch, isFalse);
await tester.pump();
expect(harness.active.activeId, 'newer');
expect((await StorageService.getInstance()).getActiveProfileId(), 'newer');
expect(SystemShelfService().debugActiveOwner, 'newer');
expect(harness.events, isNot(contains('rebind:target')));
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('selecting current profile and cancelling PIN verification do not clear', (tester) async {
final harness = await _pumpHarness(tester, channel);
addTearDown(harness.dispose);
expect(await switchProfileFromUi(harness.context, harness.active.active!), isTrue);
expect(harness.events, ['mark:owner']);
final protected = Profile.local(
id: 'protected',
displayName: 'Protected',
pinHash: computePinHash('1234'),
createdAt: DateTime(2026, 1, 3),
);
final attempt = switchProfileFromUi(harness.context, protected);
await tester.pumpAndSettle();
Navigator.of(harness.context, rootNavigator: true).pop();
await tester.pumpAndSettle();
expect(await attempt, isFalse);
expect(harness.events.where((event) => event.startsWith('clear:')), isEmpty);
});
testWidgets('activation exception restores the previous shelf owner', (tester) async {
final harness = await _pumpHarness(tester, channel, throwOnTargetActivation: true);
addTearDown(harness.dispose);
final activated = await switchProfileFromUi(harness.context, harness.target);
expect(activated, isFalse);
expect(harness.active.activeId, 'owner');
expect(SystemShelfService().debugActiveOwner, 'owner');
expect(harness.events, ['clear:owner', 'mark:target']);
});
testWidgets('failed target binding explicitly restores prior clients before returning', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
var switchCompleted = false;
final switchFuture = switchProfileFromUi(harness.context, harness.target).then((result) {
switchCompleted = true;
return result;
});
await binder.rebindStarted.future;
expect(harness.active.activeId, 'owner');
expect(binder.boundClientProfileId, isNull);
expect(switchCompleted, isFalse);
binder.allowRebind.complete();
expect(await switchFuture, isFalse);
expect(binder.boundClientProfileId, 'owner');
expect(harness.active.lastBindingSucceeded, isTrue);
expect(SystemShelfService().debugActiveOwner, 'owner');
});
testWidgets('protected switch rolls back to profile active when its activation is admitted', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
final protectedTarget = Profile.local(
id: harness.target.id,
displayName: 'Protected Target',
pinHash: computePinHash('1234'),
createdAt: harness.target.createdAt,
);
final pendingProtectedSwitch = switchProfileFromUi(harness.context, protectedTarget);
await tester.pumpAndSettle();
expect(find.text('Protected Target'), findsOneWidget);
expect(harness.active.activeId, 'owner');
expect(await switchProfileFromUi(harness.context, harness.newer), isTrue);
expect(harness.active.activeId, 'newer');
expect(binder.boundClientProfileId, 'newer');
final pinField = find.byType(TextField);
if (pinField.evaluate().isNotEmpty) {
await tester.enterText(pinField, '1234');
} else {
for (final digit in ['1', '2', '3', '4']) {
await tester.tap(find.text(digit));
}
}
await tester.pump();
await binder.targetBindingStarted.future;
binder.targetBindingRelease!.complete();
await binder.rebindStarted.future;
final restoredProfileId = harness.active.activeId;
binder.allowRebind.complete();
expect(await pendingProtectedSwitch, isFalse);
await tester.pump();
expect(restoredProfileId, 'newer');
expect(harness.active.activeId, 'newer');
expect(binder.boundClientProfileId, 'newer');
expect(SystemShelfService().debugActiveOwner, 'newer');
expect((await StorageService.getInstance()).getActiveProfileId(), 'newer');
});
testWidgets('newer activation supersedes a failed switch while rollback shelf clear is pending', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
final targetClearStarted = Completer<void>();
final allowTargetClear = Completer<void>();
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'clear') {
final ownerId = (call.arguments as Map)['ownerId'];
harness.events.add('clear:$ownerId');
if (ownerId == 'target') {
targetClearStarted.complete();
await allowTargetClear.future;
}
}
return true;
});
final failedSwitch = switchProfileFromUi(harness.context, harness.target);
await targetClearStarted.future;
expect(await switchProfileFromUi(harness.context, harness.newer), isTrue);
expect(harness.active.activeId, 'newer');
expect(binder.boundClientProfileId, 'newer');
allowTargetClear.complete();
expect(await failedSwitch, isFalse);
expect(harness.active.activeId, 'newer');
expect(binder.boundClientProfileId, 'newer');
expect(SystemShelfService().debugActiveOwner, 'newer');
expect(binder.rebindStarted.isCompleted, isFalse);
expect(harness.events, isNot(contains('mark:owner')));
expect(harness.events, isNot(contains('rebind:owner')));
expect((await StorageService.getInstance()).getActiveProfileId(), 'newer');
await tester.pump();
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('rollback waits for successive reserved switches and never rebinds the stale owner', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
final firstTargetClearStarted = Completer<void>();
final allowFirstTargetClear = Completer<void>();
final secondTargetClearStarted = Completer<void>();
final allowSecondTargetClear = Completer<void>();
var targetClearCount = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'clear') {
final ownerId = (call.arguments as Map)['ownerId'];
harness.events.add('clear:$ownerId');
if (ownerId == 'target') {
targetClearCount++;
if (targetClearCount == 1) {
firstTargetClearStarted.complete();
await allowFirstTargetClear.future;
} else {
secondTargetClearStarted.complete();
await allowSecondTargetClear.future;
}
}
}
return true;
});
final failedSwitch = switchProfileFromUi(harness.context, harness.target);
await binder.targetBindingStarted.future;
binder.targetBindingRelease!.complete();
await firstTargetClearStarted.future;
// Model the still-authoritative target binder reasserting its shelf marker
// while the first native clear is pending. The next request now reserves
// C synchronously and blocks behind that clear before entering the identity
// queue.
SystemShelfService().beginProfileSession('target');
final middleSwitch = switchProfileFromUi(harness.context, harness.newer);
final middleReservation = harness.active.identityMutationGeneration;
final latestSwitch = switchProfileFromUi(harness.context, harness.latest);
expect(harness.active.identityMutationGeneration, greaterThan(middleReservation));
expect(await latestSwitch, isTrue);
expect(harness.active.activeId, 'latest');
expect(binder.boundClientProfileId, 'latest');
allowFirstTargetClear.complete();
await secondTargetClearStarted.future;
expect(await failedSwitch, isFalse);
expect(harness.active.activeId, 'latest');
expect(harness.events, isNot(contains('mark:owner')));
expect(harness.events, isNot(contains('rebind:owner')));
allowSecondTargetClear.complete();
expect(await middleSwitch, isFalse);
await tester.pump();
expect(targetClearCount, 2);
expect((await StorageService.getInstance()).getActiveProfileId(), 'latest');
expect(SystemShelfService().debugActiveOwner, 'latest');
expect(harness.events, isNot(contains('mark:newer')));
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('new activation during a held restore write prevents stale prior-profile publication', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateOwnerRestoreWrite: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
final publishedProfileIds = <String?>[];
harness.active.addListener(() => publishedProfileIds.add(harness.active.activeId));
final failedSwitch = switchProfileFromUi(harness.context, harness.target);
await harness.restoreWriteStarted.future;
expect(harness.active.activeId, 'target');
final restoreGeneration = harness.active.identityMutationGeneration;
final newerSwitch = switchProfileFromUi(harness.context, harness.newer);
await tester.pump();
expect(harness.active.identityMutationGeneration, greaterThan(restoreGeneration));
harness.allowRestoreWrite.complete();
expect(await newerSwitch, isTrue);
expect(await failedSwitch, isFalse);
expect(harness.active.activeId, 'newer');
expect(binder.boundClientProfileId, 'newer');
expect(publishedProfileIds, isNot(contains('owner')));
expect(harness.events, isNot(contains('mark:owner')));
expect(harness.events, isNot(contains('rebind:owner')));
expect((await StorageService.getInstance()).getActiveProfileId(), 'newer');
expect(harness.identityWrites, ['target', 'owner', 'target', 'newer']);
});
testWidgets('cancelled newer PIN attempt does not suppress pending failed-switch rollback', (tester) async {
final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
final failedSwitch = switchProfileFromUi(harness.context, harness.target);
await binder.targetBindingStarted.future;
final failedGeneration = harness.active.identityMutationGeneration;
final protectedNewer = Profile.local(
id: 'protected-newer',
displayName: 'Protected Newer',
pinHash: computePinHash('1234'),
createdAt: DateTime(2026, 1, 4),
);
final cancelledSwitch = switchProfileFromUi(harness.context, protectedNewer);
await tester.pumpAndSettle();
Navigator.of(harness.context, rootNavigator: true).pop();
await tester.pumpAndSettle();
expect(await cancelledSwitch, isFalse);
expect(harness.active.identityMutationGeneration, failedGeneration);
binder.targetBindingRelease!.complete();
await binder.rebindStarted.future;
binder.allowRebind.complete();
expect(await failedSwitch, isFalse);
expect(harness.active.activeId, 'owner');
expect(binder.boundClientProfileId, 'owner');
await tester.pump();
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('failed queued activation lets superseded restore retry the prior profile', (tester) async {
final harness = await _pumpHarness(
tester,
channel,
simulateBindingRollback: true,
gateOwnerRestoreWrite: true,
failNewerIdentityWrite: true,
);
addTearDown(harness.dispose);
final binder = harness.rollbackBinder!;
final failedSwitch = switchProfileFromUi(harness.context, harness.target);
await harness.restoreWriteStarted.future;
final restoreGeneration = harness.active.identityMutationGeneration;
final newerSwitch = switchProfileFromUi(harness.context, harness.newer);
await tester.pump();
expect(harness.active.identityMutationGeneration, greaterThan(restoreGeneration));
harness.allowRestoreWrite.complete();
expect(await newerSwitch, isFalse);
await binder.rebindStarted.future;
binder.allowRebind.complete();
expect(await failedSwitch, isFalse);
expect(harness.active.activeId, 'owner');
expect(binder.boundClientProfileId, 'owner');
expect(harness.active.committedIdentityGeneration, greaterThan(restoreGeneration));
expect(harness.events, contains('mark:owner'));
expect(harness.events, contains('rebind:owner'));
expect((await StorageService.getInstance()).getActiveProfileId(), 'owner');
expect(harness.identityWrites, ['target', 'owner', 'target', 'newer', 'target', 'owner']);
});
test('superseded failing profile write cannot overwrite the newer committed profile', () async {
final database = AppDatabase.forTesting(NativeDatabase.memory());
final profiles = 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 targetWriteStarted = Completer<void>();
final allowTargetWriteToFail = Completer<void>();
final newerWriteStarted = Completer<void>();
final identityWrites = <String>[];
Future<void> writer(String profileId) async {
identityWrites.add(profileId);
await storage.setActiveProfileId(profileId);
if (profileId == 'target') {
targetWriteStarted.complete();
await allowTargetWriteToFail.future;
throw StateError('synthetic target persistence failure');
}
if (profileId == 'newer') newerWriteStarted.complete();
}
final active = _ThrowingActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
storage: storage,
activeProfileIdWriter: writer,
);
addTearDown(() async {
active.dispose();
await plexHome.dispose();
await database.close();
});
final owner = Profile.local(id: 'owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026, 1, 2));
final newer = Profile.local(id: 'newer', displayName: 'Newer', createdAt: DateTime(2026, 1, 3));
await profiles.upsert(owner);
await profiles.upsert(target);
await profiles.upsert(newer);
await storage.setActiveProfileId(owner.id);
await active.initialize();
final targetActivation = active.activate(target);
final targetFailure = expectLater(targetActivation, throwsA(isA<StateError>()));
await targetWriteStarted.future;
final newerActivation = active.activate(newer);
expect(newerWriteStarted.isCompleted, isFalse);
expect(storage.getActiveProfileId(), target.id);
expect(identityWrites, ['target']);
allowTargetWriteToFail.complete();
await targetFailure;
expect(await newerActivation, isTrue);
expect(newerWriteStarted.isCompleted, isTrue);
expect(active.activeId, newer.id);
expect(storage.getActiveProfileId(), newer.id);
expect(identityWrites, ['target', 'owner', 'newer']);
});
}
class _Harness {
_Harness({
required this.context,
required this.active,
required this.target,
required this.newer,
required this.events,
required this.latest,
required this.plexHome,
required this.rollbackBinder,
required this.database,
required this.restoreWriteStarted,
required this.allowRestoreWrite,
required this.identityWrites,
});
final BuildContext context;
final ActiveProfileProvider active;
final Profile target;
final Profile newer;
final Profile latest;
final List<String> events;
final PlexHomeService plexHome;
final _RollbackBinder? rollbackBinder;
final AppDatabase database;
final Completer<void> restoreWriteStarted;
final Completer<void> allowRestoreWrite;
final List<String> identityWrites;
Future<void> dispose() async {
rollbackBinder?.dispose();
active.dispose();
await plexHome.dispose();
await database.close();
}
}
Future<_Harness> _pumpHarness(
WidgetTester tester,
MethodChannel channel, {
bool throwOnTargetActivation = false,
bool simulateBindingRollback = false,
bool gateTargetBindingFailure = false,
bool gateOwnerRestoreWrite = false,
bool failNewerIdentityWrite = false,
}) async {
final events = <String>[];
final identityWrites = <String>[];
final database = AppDatabase.forTesting(NativeDatabase.memory());
final profiles = ProfileRegistry(database);
final connections = ConnectionRegistry(database);
final profileConnections = ProfileConnectionRegistry(database);
final storage = await StorageService.getInstance();
final restoreWriteStarted = Completer<void>();
final allowRestoreWrite = Completer<void>();
Future<void> activeProfileIdWriter(String profileId) async {
identityWrites.add(profileId);
await storage.setActiveProfileId(profileId);
if (gateOwnerRestoreWrite && profileId == 'owner') {
if (!restoreWriteStarted.isCompleted) restoreWriteStarted.complete();
await allowRestoreWrite.future;
}
if (failNewerIdentityWrite && profileId == 'newer') {
throw StateError('synthetic newer identity write failure');
}
}
final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage);
final active = _ThrowingActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
storage: storage,
activeProfileIdWriter: gateOwnerRestoreWrite || failNewerIdentityWrite ? activeProfileIdWriter : null,
);
final owner = Profile.local(id: 'owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026, 1, 2));
final newer = Profile.local(id: 'newer', displayName: 'Newer', createdAt: DateTime(2026, 1, 3));
final latest = Profile.local(id: 'latest', displayName: 'Latest', createdAt: DateTime(2026, 1, 4));
await profiles.upsert(owner);
await profiles.upsert(target);
await profiles.upsert(newer);
await profiles.upsert(latest);
await storage.setActiveProfileId(owner.id);
await active.initialize();
active.throwOnActivation = throwOnTargetActivation;
final targetBindingRelease = gateTargetBindingFailure ? Completer<void>() : null;
final rollbackBinder = simulateBindingRollback
? _RollbackBinder(active, events, targetBindingRelease: targetBindingRelease)
: null;
final ActiveProfileBinder binder = rollbackBinder ?? _Binder(events);
final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
shelf.beginProfileSession(owner.id);
SystemShelfService.debugOverrideInstance(shelf);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.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: [
ChangeNotifierProvider<ActiveProfileProvider>.value(value: active),
Provider<ActiveProfileBinder>.value(value: binder),
],
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) {
captured = context;
return const SizedBox.shrink();
},
),
),
),
),
);
await tester.pump();
return _Harness(
context: captured!,
active: active,
target: target,
newer: newer,
latest: latest,
rollbackBinder: rollbackBinder,
events: events,
plexHome: plexHome,
restoreWriteStarted: restoreWriteStarted,
allowRestoreWrite: allowRestoreWrite,
identityWrites: identityWrites,
database: database,
);
}
@@ -233,11 +233,20 @@ void main() {
await storage.setActiveProfileId(vProfile);
await storage.saveHiddenLibraries({'jf-machine:movies'});
final plannedRemoval = await planPlexAccountConnectionRemoval(
account: acct,
profileConnections: profileConnections,
);
expect(plannedRemoval.removedVirtualProfileIds, {vProfile});
expect(await connections.get(acct.id), isNotNull);
expect(await profileConnections.listAll(), hasLength(2));
final removal = await removePlexAccountConnectionAndCleanup(
account: acct,
profileConnections: profileConnections,
connections: connections,
storage: storage,
plannedRemoval: plannedRemoval,
);
expect(removal.removedVirtualProfileIds, {vProfile});