feat: jellyfin
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import 'package:drift/native.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_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ConnectionRegistry connections;
|
||||
late ProfileConnectionRegistry profileConnections;
|
||||
late ProfileRegistry profiles;
|
||||
late PlexHomeService plexHome;
|
||||
late ActiveProfileProvider activeProfile;
|
||||
late MultiServerManager manager;
|
||||
late MultiServerProvider multiServerProvider;
|
||||
late ActiveProfileBinder binder;
|
||||
late StorageService storage;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
connections = ConnectionRegistry(db);
|
||||
profileConnections = ProfileConnectionRegistry(db);
|
||||
profiles = ProfileRegistry(db);
|
||||
storage = await StorageService.getInstance();
|
||||
plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
manager = MultiServerManager();
|
||||
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
binder = ActiveProfileBinder(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
serverManager: manager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
pinPrompt: (_, {String? errorMessage}) async => null,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
binder.dispose();
|
||||
multiServerProvider.dispose();
|
||||
await activeProfile.resetForTesting();
|
||||
activeProfile.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('local profile with no connections binds successfully with empty visibility', () async {
|
||||
final profile = Profile(
|
||||
id: 'local-owner',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Owner',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
await profiles.upsert(profile);
|
||||
await storage.setActiveProfileId(profile.id);
|
||||
await activeProfile.initialize();
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||
expect(binder.debugLastBoundProfileId, profile.id);
|
||||
expect(multiServerProvider.serverIds, isEmpty);
|
||||
});
|
||||
|
||||
test('started binder does not loop forever after empty local bind', () async {
|
||||
final profile = Profile(
|
||||
id: 'local-empty',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Empty',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
await profiles.upsert(profile);
|
||||
await storage.setActiveProfileId(profile.id);
|
||||
await activeProfile.initialize();
|
||||
|
||||
var notifications = 0;
|
||||
activeProfile.addListener(() => notifications++);
|
||||
binder.start();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(activeProfile.isBinding, isFalse);
|
||||
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||
expect(binder.debugLastBoundProfileId, profile.id);
|
||||
expect(notifications, lessThan(8));
|
||||
});
|
||||
|
||||
group('Plex Home token cache policy', () {
|
||||
test('protected cold start revalidates PIN even when profile selection is not required', () {
|
||||
expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: true), isFalse);
|
||||
});
|
||||
|
||||
test('protected cold start revalidates PIN when profile selection is required', () {
|
||||
expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: true), isFalse);
|
||||
});
|
||||
|
||||
test('preverified activation uses cache once regardless of setting', () {
|
||||
expect(shouldUsePlexHomeTokenCache(preVerified: true, hasBoundOnce: false, plexProtected: true), isTrue);
|
||||
});
|
||||
|
||||
test('unprotected cold start can use cached token', () {
|
||||
expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: false), isTrue);
|
||||
});
|
||||
|
||||
test('user-initiated switches bypass cache after first bind', () {
|
||||
expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: true, plexProtected: false), isFalse);
|
||||
});
|
||||
|
||||
test('preverified activation flag is consumed once per profile', () {
|
||||
expect(binder.consumePlexHomePreVerified('plex-home-x'), isFalse);
|
||||
binder.markPlexHomePreVerified('plex-home-x');
|
||||
expect(binder.consumePlexHomePreVerified('plex-home-x'), isTrue);
|
||||
expect(binder.consumePlexHomePreVerified('plex-home-x'), isFalse);
|
||||
});
|
||||
|
||||
test('preverified activation flag isolates entries per profile id', () {
|
||||
binder.markPlexHomePreVerified('plex-home-a');
|
||||
binder.markPlexHomePreVerified('plex-home-b');
|
||||
expect(binder.consumePlexHomePreVerified('plex-home-b'), isTrue);
|
||||
expect(binder.consumePlexHomePreVerified('plex-home-a'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.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_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
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 '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ProfileRegistry registry;
|
||||
late ConnectionRegistry connections;
|
||||
late PlexHomeService plexHome;
|
||||
late ActiveProfileProvider provider;
|
||||
late StorageService storage;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
registry = ProfileRegistry(db);
|
||||
connections = ConnectionRegistry(db);
|
||||
storage = await StorageService.getInstance();
|
||||
plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: ProfileConnectionRegistry(db),
|
||||
storage: storage,
|
||||
// No accounts in tests, so the fetcher is never called.
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
provider = ActiveProfileProvider(
|
||||
registry: registry,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await provider.resetForTesting();
|
||||
provider.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('ActiveProfileProvider', () {
|
||||
test('initialize with no profiles leaves active null', () async {
|
||||
await provider.initialize();
|
||||
expect(provider.profiles, isEmpty);
|
||||
expect(provider.active, isNull);
|
||||
});
|
||||
|
||||
test('concurrent initialize calls await the same in-flight load', () async {
|
||||
final first = provider.initialize();
|
||||
final second = provider.initialize();
|
||||
expect(identical(first, second), isTrue);
|
||||
|
||||
await second;
|
||||
expect(provider.isInitialized, isTrue);
|
||||
});
|
||||
|
||||
test('initialize leaves active null when no active id stored', () async {
|
||||
// Fresh state: no auto-fallback to the first profile so the UI can
|
||||
// force the picker. The binder skips its rebind while active is null,
|
||||
// which is what avoids the surprise PIN prompt at first sign-in.
|
||||
await registry.upsert(
|
||||
Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await provider.initialize();
|
||||
expect(provider.profiles, hasLength(1));
|
||||
expect(provider.activeId, isNull);
|
||||
});
|
||||
|
||||
test('initialize clears storage when stored id is stale', () async {
|
||||
// A previously-active profile that was deleted should not keep
|
||||
// storage-scoped settings under the removed profile id.
|
||||
await registry.upsert(
|
||||
Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await storage.setActiveProfileId('ghost-id-no-longer-exists');
|
||||
await provider.initialize();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(provider.activeId, isNull);
|
||||
expect(storage.getActiveProfileId(), isNull);
|
||||
});
|
||||
|
||||
test('initialize resolves the stored active profile id', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await registry.upsert(
|
||||
Profile(id: 'p2', kind: ProfileKind.local, displayName: 'Kids', createdAt: DateTime(2026, 1, 2)),
|
||||
);
|
||||
await storage.setActiveProfileId('p2');
|
||||
await provider.initialize();
|
||||
expect(provider.activeId, 'p2');
|
||||
});
|
||||
|
||||
test('activate without PIN switches a non-protected profile', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await registry.upsert(
|
||||
Profile(id: 'p2', kind: ProfileKind.local, displayName: 'Kids', createdAt: DateTime(2026, 1, 2)),
|
||||
);
|
||||
await provider.initialize();
|
||||
final p2 = provider.profiles.firstWhere((p) => p.id == 'p2');
|
||||
final ok = await provider.activate(p2);
|
||||
expect(ok, isTrue);
|
||||
expect(provider.activeId, 'p2');
|
||||
});
|
||||
|
||||
test('clearActiveProfile clears storage and in-memory active profile', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await provider.initialize();
|
||||
await provider.activate(provider.profiles.single);
|
||||
|
||||
await provider.clearActiveProfile();
|
||||
|
||||
expect(storage.getActiveProfileId(), isNull);
|
||||
expect(provider.active, isNull);
|
||||
});
|
||||
|
||||
test('activate rejects wrong PIN for a protected local profile', () async {
|
||||
await registry.upsert(
|
||||
Profile(
|
||||
id: 'p1',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Kids',
|
||||
pinHash: computePinHash('1234'),
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
),
|
||||
);
|
||||
await provider.initialize();
|
||||
final p1 = provider.profiles.first;
|
||||
expect(await provider.activate(p1, pin: 'wrong'), isFalse);
|
||||
expect(await provider.activate(p1, pin: '1234'), isTrue);
|
||||
});
|
||||
|
||||
test('hasMultipleProfiles reflects the registry size', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await provider.initialize();
|
||||
expect(provider.hasMultipleProfiles, isFalse);
|
||||
// Latch onto the next provider notification that flips the flag,
|
||||
// instead of sleeping a fixed duration. The provider is a ChangeNotifier
|
||||
// (not a Stream), so we use addListener + Completer here rather than
|
||||
// expectLater(stream, ...) like the other profile tests.
|
||||
final flipped = Completer<void>();
|
||||
void listener() {
|
||||
if (provider.hasMultipleProfiles && !flipped.isCompleted) {
|
||||
flipped.complete();
|
||||
}
|
||||
}
|
||||
|
||||
provider.addListener(listener);
|
||||
addTearDown(() => provider.removeListener(listener));
|
||||
await registry.upsert(
|
||||
Profile(id: 'p2', kind: ProfileKind.local, displayName: 'Kids', createdAt: DateTime(2026, 1, 2)),
|
||||
);
|
||||
await flipped.future.timeout(const Duration(seconds: 2));
|
||||
expect(provider.hasMultipleProfiles, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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_service.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/services/storage_service.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),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ConnectionRegistry connections;
|
||||
late ProfileConnectionRegistry profileConnections;
|
||||
late StorageService storage;
|
||||
late PlexHomeService service;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
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('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('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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ProfileConnectionRegistry registry;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
registry = ProfileConnectionRegistry(db);
|
||||
|
||||
// Seed the parent rows that ProfileConnections now FK-references.
|
||||
// Without this, every upsert below trips the FK constraint added in
|
||||
// schema v17.
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
for (final id in ['p1', 'p2']) {
|
||||
await db
|
||||
.into(db.profiles)
|
||||
.insert(ProfilesCompanion.insert(id: id, kind: 'local', displayName: id, configJson: '{}', createdAt: now));
|
||||
}
|
||||
for (final id in ['c1', 'c2']) {
|
||||
await db
|
||||
.into(db.connections)
|
||||
.insert(ConnectionsCompanion.insert(id: id, kind: 'plex', displayName: id, configJson: '{}', createdAt: now));
|
||||
}
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('ProfileConnectionRegistry', () {
|
||||
test('listForProfile is empty initially', () async {
|
||||
expect(await registry.listForProfile('p1'), isEmpty);
|
||||
});
|
||||
|
||||
test('upsert inserts and round-trips', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 'tok', userIdentifier: 'uid-1'),
|
||||
);
|
||||
final list = await registry.listForProfile('p1');
|
||||
expect(list, hasLength(1));
|
||||
expect(list.first.userToken, 'tok');
|
||||
expect(list.first.userIdentifier, 'uid-1');
|
||||
final raw = await db.select(db.profileConnections).getSingle();
|
||||
expect(raw.userToken, isNot('tok'));
|
||||
});
|
||||
|
||||
test('first row for a profile is auto-default', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'),
|
||||
);
|
||||
final pc = await registry.get('p1', 'c1');
|
||||
expect(pc!.isDefault, isTrue);
|
||||
});
|
||||
|
||||
test('subsequent rows do not auto-replace the default', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'),
|
||||
);
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'),
|
||||
);
|
||||
final list = await registry.listForProfile('p1');
|
||||
final defaults = list.where((pc) => pc.isDefault).toList();
|
||||
expect(defaults, hasLength(1));
|
||||
expect(defaults.first.connectionId, 'c1');
|
||||
});
|
||||
|
||||
test('re-upsert preserves the existing default flag', () async {
|
||||
// Regression: re-upserting a default row used to clobber its
|
||||
// `isDefault` because the fast path always wrote `isFirst`.
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'),
|
||||
);
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'),
|
||||
);
|
||||
expect((await registry.get('p1', 'c1'))!.isDefault, isTrue);
|
||||
|
||||
// Re-upsert c1 (token refresh) — the default flag must survive.
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1-refreshed', userIdentifier: 'u1'),
|
||||
);
|
||||
expect((await registry.get('p1', 'c1'))!.isDefault, isTrue);
|
||||
expect((await registry.get('p1', 'c2'))!.isDefault, isFalse);
|
||||
});
|
||||
|
||||
test('setDefault flips the default flag exclusively', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'),
|
||||
);
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'),
|
||||
);
|
||||
await registry.setDefault('p1', 'c2');
|
||||
final list = await registry.listForProfile('p1');
|
||||
final c1 = list.firstWhere((pc) => pc.connectionId == 'c1');
|
||||
final c2 = list.firstWhere((pc) => pc.connectionId == 'c2');
|
||||
expect(c1.isDefault, isFalse);
|
||||
expect(c2.isDefault, isTrue);
|
||||
});
|
||||
|
||||
test('recordToken caches a fresh user token', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: '', userIdentifier: 'u1'),
|
||||
);
|
||||
await registry.recordToken('p1', 'c1', 'fresh-token');
|
||||
final pc = await registry.get('p1', 'c1');
|
||||
expect(pc!.userToken, 'fresh-token');
|
||||
expect(pc.tokenAcquiredAt, isNotNull);
|
||||
});
|
||||
|
||||
test('remove drops the row and promotes the next one as default', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'),
|
||||
);
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'),
|
||||
);
|
||||
// c1 is default initially. Removing it should promote c2.
|
||||
await registry.remove('p1', 'c1');
|
||||
final remaining = await registry.listForProfile('p1');
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.first.connectionId, 'c2');
|
||||
expect(remaining.first.isDefault, isTrue);
|
||||
});
|
||||
|
||||
test('removeAllForConnection cascades across profiles', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'),
|
||||
);
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p2', connectionId: 'c1', userToken: 't', userIdentifier: 'u'),
|
||||
);
|
||||
final removed = await registry.removeAllForConnection('c1');
|
||||
expect(removed, 2);
|
||||
expect(await registry.listForConnection('c1'), isEmpty);
|
||||
});
|
||||
|
||||
test('removeAllForProfile drops every row for a profile', () async {
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'),
|
||||
);
|
||||
await registry.upsert(
|
||||
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't', userIdentifier: 'u'),
|
||||
);
|
||||
final removed = await registry.removeAllForProfile('p1');
|
||||
expect(removed, 2);
|
||||
expect(await registry.listForProfile('p1'), isEmpty);
|
||||
});
|
||||
|
||||
test('upsert succeeds for a virtual plex_home profile id with no parent row', () async {
|
||||
// Regression for the v17 FK that broke Plex Home activation: virtual
|
||||
// plex_home profiles are never persisted in `profiles`, so the join
|
||||
// row's profile_id has no parent. v20 dropped the FK; this insert
|
||||
// must round-trip without a FOREIGN KEY constraint failure.
|
||||
const plexHomeId = 'plex-home-plex.acc-uuid-1234';
|
||||
await registry.upsert(
|
||||
const ProfileConnection(
|
||||
profileId: plexHomeId,
|
||||
connectionId: 'c1',
|
||||
userToken: 'home-tok',
|
||||
userIdentifier: 'uuid-1234',
|
||||
),
|
||||
);
|
||||
final list = await registry.listForProfile(plexHomeId);
|
||||
expect(list, hasLength(1));
|
||||
expect(list.first.userToken, 'home-tok');
|
||||
expect(list.first.userIdentifier, 'uuid-1234');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late ProfileRegistry registry;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
registry = ProfileRegistry(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('ProfileRegistry', () {
|
||||
test('list is empty initially', () async {
|
||||
expect(await registry.list(), isEmpty);
|
||||
});
|
||||
|
||||
test('upsert + get round-trips a local profile', () async {
|
||||
final profile = Profile(
|
||||
id: 'local-1',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Owner',
|
||||
pinHash: computePinHash('1234'),
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
await registry.upsert(profile);
|
||||
|
||||
final fetched = await registry.get('local-1');
|
||||
expect(fetched, isNotNull);
|
||||
expect(fetched!.kind, ProfileKind.local);
|
||||
expect(fetched.displayName, 'Owner');
|
||||
expect(fetched.pinHash, profile.pinHash);
|
||||
});
|
||||
|
||||
test('upsert + get round-trips a plex_home profile', () async {
|
||||
final profile = Profile(
|
||||
id: 'plex-home-acct-uuid',
|
||||
kind: ProfileKind.plexHome,
|
||||
displayName: 'Admin',
|
||||
avatarThumbUrl: 'https://plex.tv/users/abc/avatar?',
|
||||
parentConnectionId: 'acct',
|
||||
plexAdmin: true,
|
||||
plexProtected: true,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
await registry.upsert(profile);
|
||||
|
||||
final fetched = await registry.get(profile.id);
|
||||
expect(fetched, isNotNull);
|
||||
expect(fetched!.kind, ProfileKind.plexHome);
|
||||
expect(fetched.avatarThumbUrl, profile.avatarThumbUrl);
|
||||
expect(fetched.parentConnectionId, 'acct');
|
||||
expect(fetched.plexAdmin, isTrue);
|
||||
expect(fetched.plexProtected, isTrue);
|
||||
});
|
||||
|
||||
test('list orders by sortOrder then createdAt', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'a', kind: ProfileKind.local, displayName: 'A', sortOrder: 1, createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await registry.upsert(
|
||||
Profile(id: 'b', kind: ProfileKind.local, displayName: 'B', sortOrder: 0, createdAt: DateTime(2026, 1, 2)),
|
||||
);
|
||||
final list = await registry.list();
|
||||
expect(list.map((p) => p.id).toList(), ['b', 'a']);
|
||||
});
|
||||
|
||||
test('remove deletes a profile', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p', kind: ProfileKind.local, displayName: 'P', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await registry.remove('p');
|
||||
expect(await registry.get('p'), isNull);
|
||||
});
|
||||
|
||||
test('markUsed updates lastUsedAt', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p', kind: ProfileKind.local, displayName: 'P', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
final ts = DateTime(2026, 1, 5, 12, 0);
|
||||
await registry.markUsed('p', ts);
|
||||
final fetched = await registry.get('p');
|
||||
expect(fetched!.lastUsedAt, ts);
|
||||
});
|
||||
|
||||
test('upsert is idempotent (replaces existing row)', () async {
|
||||
await registry.upsert(
|
||||
Profile(id: 'p', kind: ProfileKind.local, displayName: 'Original', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await registry.upsert(
|
||||
Profile(id: 'p', kind: ProfileKind.local, displayName: 'Renamed', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
final fetched = await registry.get('p');
|
||||
expect(fetched!.displayName, 'Renamed');
|
||||
});
|
||||
|
||||
test('watchProfiles emits on insert + delete', () async {
|
||||
// Drift's `.watch()` may coalesce the initial empty snapshot with the
|
||||
// first mutation's emission when both happen inside the same
|
||||
// microtask, so we don't pin the prefix — what matters is that
|
||||
// mutations *do* propagate. `emitsThrough` skips intermediate events
|
||||
// and matches the first event satisfying the predicate, then the
|
||||
// second matcher takes over. Deterministic on slow CI runners.
|
||||
final assertion = expectLater(
|
||||
registry.watchProfiles(),
|
||||
emitsInOrder([
|
||||
emitsThrough(predicate<List<Profile>>((l) => l.length == 1 && l.first.id == 'p')),
|
||||
emitsThrough(isEmpty),
|
||||
]),
|
||||
);
|
||||
await registry.upsert(
|
||||
Profile(id: 'p', kind: ProfileKind.local, displayName: 'P', createdAt: DateTime(2026, 1, 1)),
|
||||
);
|
||||
await registry.remove('p');
|
||||
await assertion;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
|
||||
void main() {
|
||||
group('Profile', () {
|
||||
test('local profile defaults', () {
|
||||
final p = Profile(id: 'local-1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1));
|
||||
expect(p.isLocal, isTrue);
|
||||
expect(p.isPlexHome, isFalse);
|
||||
expect(p.isPinProtected, isFalse);
|
||||
expect(p.parentConnectionId, isNull);
|
||||
});
|
||||
|
||||
test('local profile with PIN is pin-protected', () {
|
||||
final p = Profile(
|
||||
id: 'local-1',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Kids',
|
||||
pinHash: computePinHash('1234'),
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
expect(p.isPinProtected, isTrue);
|
||||
});
|
||||
|
||||
test('plex_home profile pin protection follows the protected flag', () {
|
||||
final p = Profile(
|
||||
id: 'plex-home-acct1-uuid1',
|
||||
kind: ProfileKind.plexHome,
|
||||
displayName: 'Sarah',
|
||||
parentConnectionId: 'acct1',
|
||||
plexProtected: true,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
expect(p.isLocal, isFalse);
|
||||
expect(p.isPinProtected, isTrue);
|
||||
});
|
||||
|
||||
test('local PIN hash is round-tripped via configJson', () {
|
||||
final p = Profile(
|
||||
id: 'local-1',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Kids',
|
||||
pinHash: computePinHash('1234'),
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
final json = p.toConfigJson();
|
||||
final restored = Profile.fromRow(
|
||||
id: p.id,
|
||||
kind: 'local',
|
||||
displayName: p.displayName,
|
||||
avatarThumbUrl: null,
|
||||
json: json,
|
||||
sortOrder: 0,
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: null,
|
||||
);
|
||||
expect(restored.pinHash, p.pinHash);
|
||||
expect(restored.isPinProtected, isTrue);
|
||||
});
|
||||
|
||||
test('plex_home configJson round-trips with all flags', () {
|
||||
final p = Profile(
|
||||
id: 'plex-home-acct1-uuid1',
|
||||
kind: ProfileKind.plexHome,
|
||||
displayName: 'Admin',
|
||||
parentConnectionId: 'acct1',
|
||||
plexAdmin: true,
|
||||
plexRestricted: false,
|
||||
plexProtected: true,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
final json = p.toConfigJson();
|
||||
final restored = Profile.fromRow(
|
||||
id: p.id,
|
||||
kind: 'plex_home',
|
||||
displayName: p.displayName,
|
||||
avatarThumbUrl: null,
|
||||
json: json,
|
||||
sortOrder: 0,
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: null,
|
||||
);
|
||||
expect(restored.plexAdmin, isTrue);
|
||||
expect(restored.plexRestricted, isFalse);
|
||||
expect(restored.plexProtected, isTrue);
|
||||
expect(restored.parentConnectionId, 'acct1');
|
||||
});
|
||||
|
||||
test('plexHomeProfileId is deterministic', () {
|
||||
expect(plexHomeProfileId(accountConnectionId: 'plex.dev1', homeUserUuid: 'uuid-1'), 'plex-home-plex.dev1-uuid-1');
|
||||
});
|
||||
|
||||
test('parsePlexHomeProfileId round-trips a real hyphenated UUID', () {
|
||||
// Real Plex Home UUIDs are 36-char standard UUIDs (4 internal hyphens),
|
||||
// and accountConnectionId can carry hyphens too (e.g. plex.client-id).
|
||||
const acct = 'plex.client-id-123';
|
||||
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef0123456789';
|
||||
final id = plexHomeProfileId(accountConnectionId: acct, homeUserUuid: uuid);
|
||||
final parsed = parsePlexHomeProfileId(id);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.accountConnectionId, acct);
|
||||
expect(parsed.homeUserUuid, uuid);
|
||||
});
|
||||
|
||||
test('parsePlexHomeProfileId rejects non-Plex-Home ids', () {
|
||||
expect(parsePlexHomeProfileId('local-1'), isNull);
|
||||
expect(parsePlexHomeProfileId('plex-home-only'), isNull);
|
||||
expect(parsePlexHomeProfileId('plex-home-acct-not-a-uuid'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PIN hashing', () {
|
||||
test('computePinHash is deterministic for the same input', () {
|
||||
expect(computePinHash('1234'), computePinHash('1234'));
|
||||
});
|
||||
|
||||
test('computePinHash differs for different inputs', () {
|
||||
expect(computePinHash('1234'), isNot(computePinHash('5678')));
|
||||
});
|
||||
|
||||
test('verifyPin matches its hash', () {
|
||||
final h = computePinHash('4242');
|
||||
expect(verifyPin('4242', h), isTrue);
|
||||
expect(verifyPin('1111', h), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profiles_view.dart';
|
||||
|
||||
void main() {
|
||||
group('visibleProfileConnections', () {
|
||||
test('keeps all local profile connection rows', () {
|
||||
final profile = Profile(
|
||||
id: 'local-1',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Owner',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
const rows = [
|
||||
ProfileConnection(profileId: 'local-1', connectionId: 'plex-1', userIdentifier: 'u1'),
|
||||
ProfileConnection(profileId: 'local-1', connectionId: 'jellyfin-1', userIdentifier: 'u2'),
|
||||
];
|
||||
|
||||
expect(visibleProfileConnections(profile, rows), rows);
|
||||
});
|
||||
|
||||
test('filters Plex Home parent token cache row', () {
|
||||
final profile = Profile(
|
||||
id: 'plex-home-plex-1-user-1',
|
||||
kind: ProfileKind.plexHome,
|
||||
displayName: 'Kid',
|
||||
parentConnectionId: 'plex-1',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
const rows = [
|
||||
ProfileConnection(profileId: 'plex-home-plex-1-user-1', connectionId: 'plex-1', userIdentifier: 'user-1'),
|
||||
ProfileConnection(profileId: 'plex-home-plex-1-user-1', connectionId: 'jellyfin-1', userIdentifier: 'user-2'),
|
||||
];
|
||||
|
||||
final visible = visibleProfileConnections(profile, rows);
|
||||
|
||||
expect(visible, hasLength(1));
|
||||
expect(visible.single.connectionId, 'jellyfin-1');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user