Files
plezy/test/profiles/plex_home_service_test.dart

724 lines
27 KiB
Dart

import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/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';
PlexHomeUser _user(String uuid, {bool admin = false, bool protected = false, String name = 'User'}) {
return PlexHomeUser(
id: 0,
uuid: uuid,
title: name,
username: null,
email: null,
friendlyName: null,
thumb: 'https://plex.tv/users/$uuid/avatar',
hasPassword: false,
restricted: false,
updatedAt: null,
admin: admin,
guest: false,
protected: protected,
);
}
PlexAccountConnection _account(String id) {
return PlexAccountConnection(
id: id,
accountToken: 'tok-$id',
clientIdentifier: 'cid-$id',
accountLabel: 'acct-$id',
createdAt: DateTime(2026, 1, 1),
);
}
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);
storage = await StorageService.getInstance();
});
tearDown(() async {
await service.dispose();
await db.close();
});
group('PlexHomeService', () {
test('refresh fetches and caches users for a connection', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [_user('admin-uuid', admin: true), _user('kid-uuid', protected: true)],
);
final acct = _account('plex.dev1');
await connections.upsert(acct);
await service.refresh(acct);
expect(service.current[acct.id], hasLength(2));
expect(service.current[acct.id]!.firstWhere((u) => u.admin).uuid, 'admin-uuid');
});
test('identical refreshes do not emit a second cache snapshot', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [_user('same-user')],
);
final acct = _account('plex.same');
await connections.upsert(acct);
final emissions = <Map<String, List<PlexHomeUser>>>[];
final subscription = service.stream.listen(emissions.add);
addTearDown(subscription.cancel);
await service.refresh(acct);
await service.refresh(acct);
await Future<void>.delayed(Duration.zero);
expect(emissions, hasLength(2));
expect(emissions.last[acct.id]!.single.uuid, 'same-user');
});
test('refresh persists users to SharedPreferences', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [_user('uuid-1')],
);
final acct = _account('plex.dev2');
await connections.upsert(acct);
await service.refresh(acct);
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNotNull);
});
test('start hydrates the cache from SharedPreferences', () async {
// Pre-seed the cache.
await storage.savePlexHomeUsersCache('plex.dev3', [_user('seeded-uuid').toJson()]);
final acct = _account('plex.dev3');
await connections.upsert(acct);
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [], // background refresh returns empty
);
await service.start();
// Cache hydrated synchronously from SharedPreferences before any
// background fetch resolves.
expect(service.current[acct.id], hasLength(1));
expect(service.current[acct.id]!.first.uuid, 'seeded-uuid');
});
test('reloadFromStorage picks up caches written after startup', () async {
final refreshBlocker = Completer<List<PlexHomeUser>>();
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) => refreshBlocker.future,
);
addTearDown(() {
if (!refreshBlocker.isCompleted) refreshBlocker.complete(const []);
});
await service.start();
expect(service.current, isEmpty);
final acct = _account('plex.migrated');
await connections.upsert(acct);
await storage.savePlexHomeUsersCache(acct.id, [_user('migrated-home-user').toJson()]);
await service.reloadFromStorage();
expect(service.current[acct.id], hasLength(1));
expect(service.current[acct.id]!.single.uuid, 'migrated-home-user');
});
test('concurrent start calls await the same in-flight startup', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final first = service.start();
final second = service.start();
expect(identical(first, second), isTrue);
await second;
});
test('removing a Plex connection clears its cache slot', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [_user('uuid-1')],
);
final acct = _account('plex.dev4');
await connections.upsert(acct);
await service.refresh(acct);
expect(service.current[acct.id], isNotNull);
await service.start();
// Wait for the service's own stream to emit a snapshot without
// `acct.id` instead of a fixed-duration sleep — deterministic on slow
// CI runners and matches when the listener actually settles, not just
// 30ms after the remove() future resolves.
final cleared = expectLater(
service.stream,
emitsThrough(predicate<Map<String, List<PlexHomeUser>>>((m) => !m.containsKey(acct.id))),
);
await connections.remove(acct.id);
await cleared;
expect(service.current[acct.id], isNull);
expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull);
});
test('materializeFirstPlexHome wraps the first cached account', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [
_user('admin-uuid', admin: true, name: 'Admin'),
_user('kid-uuid', name: 'Kid'),
],
);
final acct = _account('plex.dev5');
await connections.upsert(acct);
await service.refresh(acct);
final home = await service.materializeFirstPlexHome();
expect(home, isNotNull);
expect(home!.users, hasLength(2));
expect(home.adminUser?.uuid, 'admin-uuid');
});
test('materializeFirstPlexHome waits for startup cache hydration', () async {
await storage.savePlexHomeUsersCache('plex.dev-cached', [_user('cached-admin', admin: true).toJson()]);
final acct = _account('plex.dev-cached');
await connections.upsert(acct);
final refreshBlocker = Completer<List<PlexHomeUser>>();
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) => refreshBlocker.future,
);
addTearDown(() {
if (!refreshBlocker.isCompleted) refreshBlocker.complete(const []);
});
final home = await service.materializeFirstPlexHome();
expect(home, isNotNull);
expect(home!.adminUser?.uuid, 'cached-admin');
});
test('clearAll wipes both memory and disk caches', () async {
service = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => [_user('uuid-1')],
);
final acct = _account('plex.dev6');
await connections.upsert(acct);
await service.refresh(acct);
await service.clearAll();
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));
});
});
}