import 'dart:convert'; import 'package:plezy/media/ids.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/services/credential_vault.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import '../test_helpers/prefs.dart'; void main() { late AppDatabase db; late JellyfinApiCache cache; setUp(() { resetSharedPreferencesForTest(); db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); cache = JellyfinApiCache.instance; }); tearDown(() async { await db.close(); }); // Minimal Jellyfin BaseItemDto-shaped payload. The mapper only needs Id + // Type + Name to produce a valid MediaItem; the rest is pass-through. Map jellyfinItem({String id = 'item-1', String name = 'Hello', String type = 'Movie'}) { return {'Id': id, 'Type': type, 'Name': name}; } // Insert a Jellyfin connection row with the production-shape id // (`${machineId}/$userId`) and configJson containing the bare serverName. Future insertJellyfinConnection({ required String machineId, required String userId, required String serverName, String accessToken = 'token', }) async { await db .into(db.connections) .insert( ConnectionsCompanion.insert( id: '$machineId/$userId', kind: 'jellyfin', displayName: 'someone · $serverName', configJson: jsonEncode({ 'baseUrl': 'http://example.lan', 'serverName': serverName, 'serverMachineId': machineId, 'userId': userId, 'userName': 'someone', 'accessToken': accessToken, 'deviceId': 'device', }), createdAt: DateTime.now().millisecondsSinceEpoch, ), ); } // Cache rows are written by [JellyfinClient] under // `serverId:/Users/{userId}/Items/{itemId}` — mirror the shape exactly so // we exercise the same lookup pattern. Future putItemRow({ required ServerId serverId, required String userId, required String itemId, Map? data, bool pinned = false, }) async { final payload = data ?? jellyfinItem(id: itemId); await db .into(db.apiCache) .insert( ApiCacheCompanion.insert( cacheKey: '$serverId:/Users/$userId/Items/$itemId', data: jsonEncode(payload), pinned: Value(pinned), ), ); } group('getMetadata', () { test('resolves serverName via the Jellyfin compound connection id (machineId/userId)', () async { // Production stores connection rows under `${machineId}/$userId` while // [JellyfinClient.serverId] returns the bare machineId. The cache // lookup must reconcile the mismatch — otherwise downloaded Jellyfin // items lose their metadata after an app reload. const machineId = 'jf-machine'; const userId = 'jf-user'; await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin'); await putItemRow( serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: jellyfinItem(id: 'item-1', name: 'A Movie'), ); final meta = await cache.getMetadata(ServerId(machineId), 'item-1'); expect(meta, isNotNull, reason: 'cache lookup must succeed despite id-format mismatch'); expect(meta!.title, 'A Movie'); expect(meta.serverId, machineId); expect(meta.serverName, 'My Jellyfin', reason: 'serverName is the bare value, not the compound displayName'); }); test('returns null when the connection row is missing', () async { // Cache row exists but no Connections row → lookup can't resolve serverName. await putItemRow(serverId: ServerId('orphan'), userId: 'u', itemId: 'item-1'); expect(await cache.getMetadata(ServerId('orphan'), 'item-1'), isNull); }); test('absolutizes image paths against the connection baseUrl + accessToken', () async { // Regression: cached items used to skip absolutization, leaking raw // `/Items/.../Images/Primary?tag=...` paths into the download manager // → Cronet rejected with net::ERR_INVALID_URL. const machineId = 'jf-machine'; const userId = 'jf-user'; await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin'); await putItemRow( serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: { 'Id': 'item-1', 'Type': 'Movie', 'Name': 'A Movie', 'ImageTags': {'Primary': 'tag-abc', 'Logo': 'tag-logo'}, }, ); final meta = await cache.getMetadata(ServerId(machineId), 'item-1'); expect(meta, isNotNull); expect(meta!.thumbPath, 'http://example.lan/Items/item-1/Images/Primary?tag=tag-abc&api_key=token'); expect(meta.clearLogoPath, 'http://example.lan/Items/item-1/Images/Logo?tag=tag-logo&api_key=token'); }); test('absolutizes image paths with decrypted accessToken', () async { const machineId = 'jf-machine'; const userId = 'jf-user'; await insertJellyfinConnection( machineId: machineId, userId: userId, serverName: 'My Jellyfin', accessToken: await CredentialVault.protect('secret-token'), ); await putItemRow( serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: { 'Id': 'item-1', 'Type': 'Movie', 'Name': 'A Movie', 'ImageTags': {'Primary': 'tag-abc'}, }, ); final meta = await cache.getMetadata(ServerId(machineId), 'item-1'); expect(meta, isNotNull); expect(meta!.thumbPath, contains('api_key=secret-token')); expect(meta.thumbPath, isNot(contains('enc:v1:'))); }); test('scopes UserData by Jellyfin compound connection id', () async { const machineId = 'jf-machine'; await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); await putItemRow( serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1', name: 'For A'), 'UserData': {'Played': false, 'PlayCount': 0}, }, ); await putItemRow( serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1', name: 'For B'), 'UserData': {'Played': true, 'PlayCount': 1}, }, ); final a = await cache.getMetadata(ServerId('$machineId/user-a'), 'item-1'); final b = await cache.getMetadata(ServerId('$machineId/user-b'), 'item-1'); expect(a, isNotNull); expect(b, isNotNull); expect(a!.serverId, machineId); expect(a.isWatched, isFalse); expect(a.title, 'For A'); expect(b!.serverId, machineId); expect(b.isWatched, isTrue); expect(b.title, 'For B'); }); }); group('getAllPinnedMetadata', () { test('aggregates pinned items keyed by globalKey across multiple users on the same server', () async { // Same machineId, two users → two connection rows. Both pin items. // Both should resolve via the prefix match. const machineId = 'jf-machine'; await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1', pinned: true); await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-2', pinned: true); // Unpinned row is filtered out. await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-3'); final pinned = await cache.getAllPinnedMetadata(); expect(pinned.keys.toSet(), {'$machineId:item-1', '$machineId:item-2'}); expect(pinned['$machineId:item-1']!.serverName, 'Shared JF'); }); test('compound scope filtering selects only that user from legacy bare-scope rows', () async { const machineId = 'jf-machine'; await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); await putItemRow( serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-a', data: jellyfinItem(id: 'item-a', name: 'For A'), pinned: true, ); await putItemRow( serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-b', data: jellyfinItem(id: 'item-b', name: 'For B'), pinned: true, ); final pinned = await cache.getAllPinnedMetadata(cacheServerIds: {ServerId('$machineId/user-b')}); expect(pinned.keys, ['$machineId:item-b']); expect(pinned.values.single.title, 'For B'); }); test('skips pinned rows whose serverId has no matching connection', () async { await putItemRow(serverId: ServerId('orphan-machine'), userId: 'u', itemId: 'lost', pinned: true); expect(await cache.getAllPinnedMetadata(), isEmpty); }); test('keeps same-server Jellyfin users addressable by compound pinned keys', () async { const machineId = 'jf-machine'; await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); await putItemRow( serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1', name: 'For A'), 'UserData': {'Played': false, 'PlayCount': 0}, }, pinned: true, ); await putItemRow( serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1', name: 'For B'), 'UserData': {'Played': true, 'PlayCount': 1}, }, pinned: true, ); final pinned = await cache.getAllPinnedMetadata(); expect(pinned['$machineId:item-1'], isNull); expect(pinned['$machineId/user-a:item-1']!.title, 'For A'); expect(pinned['$machineId/user-b:item-1']!.title, 'For B'); expect(pinned['$machineId/user-a:item-1']!.serverId, machineId); expect(pinned['$machineId/user-b:item-1']!.serverId, machineId); }); }); group('pinForOffline', () { test('pins by user-segment wildcard so a single call covers any user', () async { const machineId = 'jf-machine'; await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1'); await putItemRow(serverId: ServerId(machineId), userId: 'user-b', itemId: 'item-1'); await putItemRow(serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-2'); await cache.pinForOffline(ServerId(machineId), 'item-1'); // Both per-user rows for item-1 get pinned, item-2 stays unpinned. final rows = await db.select(db.apiCache).get(); final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet(); expect(pinnedKeys, {'$machineId:/Users/user-a/Items/item-1', '$machineId:/Users/user-b/Items/item-1'}); }); test('pins only the requested compound Jellyfin user scope', () async { const machineId = 'jf-machine'; await putItemRow(serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1'); await putItemRow(serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1'); await cache.pinForOffline(ServerId('$machineId/user-a'), 'item-1'); final rows = await db.select(db.apiCache).get(); final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet(); expect(pinnedKeys, {'$machineId/user-a:/Users/user-a/Items/item-1'}); }); }); group('applyWatchState', () { test('mutates only the requested compound Jellyfin user scope', () async { const machineId = 'jf-machine'; await putItemRow( serverId: ServerId('$machineId/user-a'), userId: 'user-a', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1'), 'UserData': {'Played': false, 'PlayCount': 0}, }, ); await putItemRow( serverId: ServerId('$machineId/user-b'), userId: 'user-b', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1'), 'UserData': {'Played': false, 'PlayCount': 0}, }, ); await cache.applyWatchState(serverId: ServerId('$machineId/user-a'), itemId: 'item-1', isWatched: true); final rows = await db.select(db.apiCache).get(); final byKey = {for (final row in rows) row.cacheKey: jsonDecode(row.data) as Map}; expect((byKey['$machineId/user-a:/Users/user-a/Items/item-1']!['UserData'] as Map)['Played'], isTrue); expect((byKey['$machineId/user-b:/Users/user-b/Items/item-1']!['UserData'] as Map)['Played'], isFalse); }); test('bare Jellyfin server id updates the only cached user scope', () async { const machineId = 'jf-machine'; await putItemRow( serverId: ServerId(machineId), userId: 'user-a', itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1'), 'UserData': {'Played': false, 'PlayCount': 0}, }, ); await cache.applyWatchState(serverId: ServerId(machineId), itemId: 'item-1', isWatched: true); final row = await db.select(db.apiCache).getSingle(); expect((jsonDecode(row.data)['UserData'] as Map)['Played'], isTrue); }); test('bare Jellyfin server id skips ambiguous multi-user cache updates', () async { const machineId = 'jf-machine'; for (final userId in ['user-a', 'user-b']) { await putItemRow( serverId: ServerId(machineId), userId: userId, itemId: 'item-1', data: { ...jellyfinItem(id: 'item-1'), 'UserData': {'Played': false, 'PlayCount': 0}, }, ); } await cache.applyWatchState(serverId: ServerId(machineId), itemId: 'item-1', isWatched: true); final rows = await db.select(db.apiCache).get(); for (final row in rows) { expect((jsonDecode(row.data)['UserData'] as Map)['Played'], isFalse); } }); }); }