feat: jellyfin

This commit is contained in:
edde746
2026-05-01 01:20:36 +02:00
parent 4080c812d2
commit 31d2d9dc98
408 changed files with 56022 additions and 13747 deletions
@@ -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);
});
});
}
+213
View File
@@ -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');
});
});
}
+125
View File
@@ -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;
});
});
}
+127
View File
@@ -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);
});
});
}
+42
View File
@@ -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');
});
});
}