fix(runtime): harden application service boundaries
This commit is contained in:
@@ -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)));
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user