724 lines
27 KiB
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));
|
|
});
|
|
});
|
|
}
|