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/media/media_backend.dart'; import 'package:plezy/services/api_cache.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/media_items.dart'; void main() { late AppDatabase db; late PlexApiCache cache; setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); // PlexApiCache is a singleton initialized via static factory. Re-init // against the in-memory db for each test for full isolation. PlexApiCache.initialize(db); cache = PlexApiCache.instance; }); tearDown(() async { await db.close(); }); // Helper: minimal Plex MediaContainer payload that PlexCacheParser can parse. Map mediaContainer({ String ratingKey = '42', String title = 'Item', Object? librarySectionID, String? librarySectionTitle, int? viewCount, int? viewOffset, int? lastViewedAt, }) => { 'MediaContainer': { 'librarySectionID': ?librarySectionID, 'librarySectionTitle': ?librarySectionTitle, 'Metadata': [ { 'ratingKey': ratingKey, 'title': title, 'type': 'movie', 'viewCount': ?viewCount, 'viewOffset': ?viewOffset, 'lastViewedAt': ?lastViewedAt, }, ], }, }; // ============================================================ // Singleton // ============================================================ group('singleton', () { test('initialize swaps the underlying database', () async { final newDb = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(newDb); expect(identical(PlexApiCache.instance.database, newDb), isTrue); await newDb.close(); }); test('registered cleanup ignores backend initialization order and preserves pinned rows', () async { await cache.put(ServerId('srv'), '/volatile', {'value': 1}); await cache.put(ServerId('srv'), '/pinned', {'value': 2}); await cache.pin(ServerId('srv'), '/pinned'); // Register the other backend last; cleanup must not depend on whichever // concrete singleton happened to initialize most recently. JellyfinApiCache.initialize(db); await ApiCache.clearRegisteredVolatile(); expect(await cache.get(ServerId('srv'), '/volatile'), isNull); expect(await cache.get(ServerId('srv'), '/pinned'), {'value': 2}); }); test('registering a new database drops stale backend dispatch entries', () async { final newDb = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(newDb); expect(() => ApiCache.forBackend(MediaBackend.plex), throwsStateError); expect(identical(ApiCache.forBackend(MediaBackend.jellyfin).database, newDb), isTrue); await newDb.close(); }); }); group('shared row decoding', () { test('drops malformed rows without discarding valid siblings', () { final decoded = decodeCachedMediaRows( ['{"id":"first"}', 'not json', '[]', '{"id":"last"}'], serializedData: (row) => row, decode: (_, json) { final id = json['id'] as String; return MapEntry(id, testMediaItem(id: id)); }, ); expect(decoded.keys, ['first', 'last']); expect(decoded['first']?.id, 'first'); expect(decoded['last']?.id, 'last'); }); }); // ============================================================ // get / put — cache hit and miss // ============================================================ group('get / put', () { test('miss returns null for an unknown key', () async { expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull); }); test('put + get round-trip preserves the JSON map', () async { final payload = mediaContainer(ratingKey: '1', title: 'Hello'); await cache.put(ServerId('srv'), '/library/metadata/1', payload); final hit = await cache.get(ServerId('srv'), '/library/metadata/1'); expect(hit, isNotNull); expect(hit, equals(payload)); }); test('transfer namespace maps cached metadata back to the public server identity', () async { final transferScope = buildPlexTransferScopeId(ServerId('srv')); await cache.put( transferScope.cacheServerId, '/library/metadata/1', mediaContainer(ratingKey: '1', title: 'Transferred'), ); await cache.pinForOffline(transferScope.cacheServerId, '1'); final item = await cache.getMetadata(transferScope.cacheServerId, '1'); final all = await cache.getAllPinnedMetadata(cacheServerIds: {transferScope.cacheServerId}); expect(item?.serverId, 'srv'); expect(item?.globalKey, 'srv:1'); expect(all.keys, {'srv:1'}); }); test('put on existing key overwrites prior data (insertOnConflictUpdate)', () async { await cache.put(ServerId('srv'), '/library/metadata/1', { 'MediaContainer': { 'Metadata': [ {'title': 'first'}, ], }, }); await cache.put(ServerId('srv'), '/library/metadata/1', { 'MediaContainer': { 'Metadata': [ {'title': 'second'}, ], }, }); final hit = await cache.get(ServerId('srv'), '/library/metadata/1'); expect(((hit!['MediaContainer'] as Map)['Metadata'] as List).first['title'], 'second'); }); test('keys are namespaced by serverId — same endpoint on different servers is isolated', () async { await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: 'A')); await cache.put(ServerId('srv-b'), '/library/metadata/1', mediaContainer(ratingKey: 'B')); final a = await cache.get(ServerId('srv-a'), '/library/metadata/1'); final b = await cache.get(ServerId('srv-b'), '/library/metadata/1'); expect(((a!['MediaContainer'] as Map)['Metadata'] as List).first['ratingKey'], 'A'); expect(((b!['MediaContainer'] as Map)['Metadata'] as List).first['ratingKey'], 'B'); }); test('put writes a fresh cachedAt timestamp on overwrite', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); final firstRow = await (db.select( db.apiCache, )..where((t) => t.cacheKey.equals('srv:/library/metadata/1'))).getSingle(); // Wait one tick so DateTime.now() advances. await Future.delayed(const Duration(milliseconds: 5)); await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(title: 'Updated')); final secondRow = await (db.select( db.apiCache, )..where((t) => t.cacheKey.equals('srv:/library/metadata/1'))).getSingle(); expect(secondRow.cachedAt.isAfter(firstRow.cachedAt) || secondRow.cachedAt == firstRow.cachedAt, isTrue); }); }); // ============================================================ // deleteForServer / deleteForItem / clearAll // ============================================================ group('deletion', () { test('deleteForServer wipes only the targeted serverId', () async { await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1')); await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2')); await cache.put(ServerId('srv-b'), '/library/metadata/1', mediaContainer(ratingKey: '1')); await cache.deleteForServer(ServerId('srv-a')); expect(await cache.get(ServerId('srv-a'), '/library/metadata/1'), isNull); expect(await cache.get(ServerId('srv-a'), '/library/metadata/2'), isNull); expect(await cache.get(ServerId('srv-b'), '/library/metadata/1'), isNotNull); }); test('deleteForItem removes both metadata and children endpoints', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); await cache.put(ServerId('srv'), '/library/metadata/1/children', mediaContainer()); await cache.put(ServerId('srv'), '/library/metadata/2', mediaContainer()); await cache.deleteForItem(ServerId('srv'), '1'); expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull); expect(await cache.get(ServerId('srv'), '/library/metadata/1/children'), isNull); // Unrelated item not affected. expect(await cache.get(ServerId('srv'), '/library/metadata/2'), isNotNull); }); test('clearAll wipes every row across servers', () async { await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer()); await cache.put(ServerId('srv-b'), '/library/metadata/2', mediaContainer()); await cache.clearAll(); expect(await db.select(db.apiCache).get(), isEmpty); }); test('clearVolatile preserves pinned offline metadata', () async { await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1')); await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2')); await cache.pinForOffline(ServerId('srv-a'), '1'); await cache.clearVolatile(); expect(await cache.get(ServerId('srv-a'), '/library/metadata/1'), isNotNull); expect(await cache.get(ServerId('srv-a'), '/library/metadata/2'), isNull); }); }); // ============================================================ // Pinning // ============================================================ group('pinning', () { test('isPinned defaults to false for a freshly cached item', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isFalse); }); test('isPinned returns false when the item is not cached at all', () async { expect(await cache.isPinnedRatingKey(ServerId('srv'), 'missing'), isFalse); }); test('pinForOffline marks the row as pinned', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); await cache.pinForOffline(ServerId('srv'), '1'); expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isTrue); }); test('unpinForOffline reverts the pin', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); await cache.pinForOffline(ServerId('srv'), '1'); await cache.unpinForOffline(ServerId('srv'), '1'); expect(await cache.isPinnedRatingKey(ServerId('srv'), '1'), isFalse); }); test('pinForOffline on missing row is a no-op (no insert, no throw)', () async { await cache.pinForOffline(ServerId('srv'), 'missing'); expect(await cache.isPinnedRatingKey(ServerId('srv'), 'missing'), isFalse); }); test('getPinnedKeys extracts ratingKeys from pinned rows for the server', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); await cache.put(ServerId('srv'), '/library/metadata/2', mediaContainer()); await cache.put(ServerId('srv'), '/library/metadata/3', mediaContainer()); await cache.put(ServerId('other'), '/library/metadata/4', mediaContainer()); await cache.pinForOffline(ServerId('srv'), '1'); await cache.pinForOffline(ServerId('srv'), '3'); await cache.pinForOffline(ServerId('other'), '4'); final keys = await cache.getPinnedKeys(ServerId('srv')); expect(keys, equals({'1', '3'})); }); test('getPinnedKeys ignores cache rows whose endpoint is not /library/metadata/', () async { // Cached at a non-metadata endpoint — its key shape won't match the regex. await db .into(db.apiCache) .insert(ApiCacheCompanion.insert(cacheKey: 'srv:/library/sections/1/all', data: jsonEncode({'foo': 'bar'}))); // Force-pin via raw update to exercise the regex skip path. await (db.update(db.apiCache)..where((t) => t.cacheKey.equals('srv:/library/sections/1/all'))).write( const ApiCacheCompanion(pinned: Value(true)), ); expect(await cache.getPinnedKeys(ServerId('srv')), isEmpty); }); test('getPinnedKeys handles alphanumeric ratingKeys', () async { // Plex sometimes uses alphanumeric ratingKeys (e.g. for online-content). await cache.put(ServerId('srv'), '/library/metadata/abc-123', mediaContainer(ratingKey: 'abc-123')); await cache.pinForOffline(ServerId('srv'), 'abc-123'); final keys = await cache.getPinnedKeys(ServerId('srv')); expect(keys, equals({'abc-123'})); }); }); // ============================================================ // getMetadata / getAllPinnedMetadata // ============================================================ group('metadata extraction', () { test('getMetadata returns null when the key is not cached', () async { expect(await cache.getMetadata(ServerId('srv'), 'missing'), isNull); }); test('getMetadata returns null when cached payload has no Metadata array', () async { await cache.put(ServerId('srv'), '/library/metadata/empty', { 'MediaContainer': {'size': 0}, }); expect(await cache.getMetadata(ServerId('srv'), 'empty'), isNull); }); test('getMetadata parses MediaContainer.Metadata[0] and tags it with serverId', () async { await cache.put(ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello')); final meta = await cache.getMetadata(ServerId('srv'), '42'); expect(meta, isNotNull); expect(meta!.id, '42'); expect(meta.title, 'Hello'); expect(meta.serverId, 'srv'); }); test('getMetadata preserves hoisted MediaContainer library fields', () async { await cache.put( ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: '7', librarySectionTitle: 'Movies'), ); final meta = await cache.getMetadata(ServerId('srv'), '42'); expect(meta, isNotNull); expect(meta!.libraryId, '7'); expect(meta.libraryTitle, 'Movies'); }); test('getAllPinnedMetadata returns an empty map when nothing is pinned', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(ratingKey: '1')); // No pin yet. expect(await cache.getAllPinnedMetadata(), isEmpty); }); test('getAllPinnedMetadata aggregates pinned items across servers, keyed by globalKey', () async { await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1', title: 'A1')); await cache.put(ServerId('srv-a'), '/library/metadata/2', mediaContainer(ratingKey: '2', title: 'A2')); await cache.put(ServerId('srv-b'), '/library/metadata/9', mediaContainer(ratingKey: '9', title: 'B9')); // One unpinned row to verify it's filtered out. await cache.put(ServerId('srv-b'), '/library/metadata/10', mediaContainer(ratingKey: '10', title: 'B10')); await cache.pinForOffline(ServerId('srv-a'), '1'); await cache.pinForOffline(ServerId('srv-a'), '2'); await cache.pinForOffline(ServerId('srv-b'), '9'); final result = await cache.getAllPinnedMetadata(); expect(result.keys.toSet(), {'srv-a:1', 'srv-a:2', 'srv-b:9'}); expect(result['srv-a:1']!.title, 'A1'); expect(result['srv-a:1']!.serverId, 'srv-a'); expect(result['srv-b:9']!.title, 'B9'); expect(result['srv-b:9']!.serverId, 'srv-b'); }); test('getAllPinnedMetadata preserves hoisted MediaContainer library fields', () async { await cache.put( ServerId('srv'), '/library/metadata/42', mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: 7, librarySectionTitle: 'Movies'), ); await cache.pinForOffline(ServerId('srv'), '42'); final result = await cache.getAllPinnedMetadata(); expect(result['srv:42']!.libraryId, '7'); expect(result['srv:42']!.libraryTitle, 'Movies'); }); test('getAllPinnedMetadata skips rows whose key is not a metadata endpoint', () async { // Insert a pinned row at a non-metadata endpoint via raw insert. await db .into(db.apiCache) .insert( ApiCacheCompanion.insert( cacheKey: 'srv:/library/sections/1/all', data: jsonEncode(mediaContainer()), pinned: const Value(true), ), ); await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer(ratingKey: '1')); await cache.pinForOffline(ServerId('srv'), '1'); final result = await cache.getAllPinnedMetadata(); expect(result.keys.toSet(), {'srv:1'}); }); test('getAllPinnedMetadata silently skips rows with malformed JSON', () async { // Bad-JSON pinned row. await db .into(db.apiCache) .insert( ApiCacheCompanion.insert( cacheKey: 'srv:/library/metadata/bad', data: 'not-json', pinned: const Value(true), ), ); // Good pinned row. await cache.put(ServerId('srv'), '/library/metadata/good', mediaContainer(ratingKey: 'good', title: 'OK')); await cache.pinForOffline(ServerId('srv'), 'good'); final result = await cache.getAllPinnedMetadata(); expect(result.keys, contains('srv:good')); expect(result.keys, isNot(contains('srv:bad'))); }); test('profile-scoped rows stay isolated while projecting the public item identity', () async { final publicServerId = ServerId('plex-public'); final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a').cacheServerId; final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b').cacheServerId; const ratingKey = '42'; const endpoint = '/library/metadata/$ratingKey'; await cache.put( scopeA, endpoint, mediaContainer( ratingKey: ratingKey, title: 'Profile A', viewCount: 0, viewOffset: 12000, lastViewedAt: 1700000001, ), ); await cache.put( scopeB, endpoint, mediaContainer(ratingKey: ratingKey, title: 'Profile B', viewCount: 1, viewOffset: 0, lastViewedAt: 1700000002), ); await cache.pinForOffline(scopeA, ratingKey); await cache.pinForOffline(scopeB, ratingKey); final singleA = await cache.getMetadata(scopeA, ratingKey); final singleB = await cache.getMetadata(scopeB, ratingKey); expect(singleA, isNotNull); expect(singleA!.serverId, 'plex-public'); expect(singleA.globalKey, 'plex-public:42'); expect(singleA.isWatched, isFalse); expect(singleA.viewOffsetMs, 12000); expect(singleA.lastViewedAt, 1700000001); expect(singleB, isNotNull); expect(singleB!.serverId, 'plex-public'); expect(singleB.globalKey, 'plex-public:42'); expect(singleB.isWatched, isTrue); expect(singleB.viewOffsetMs, 0); expect(singleB.lastViewedAt, 1700000002); final bulkA = await cache.getAllPinnedMetadata(cacheServerIds: {scopeA}); final bulkB = await cache.getAllPinnedMetadata(cacheServerIds: {scopeB}); expect(bulkA.keys, ['plex-public:42']); expect(bulkA['plex-public:42']!.title, 'Profile A'); expect(bulkA['plex-public:42']!.viewOffsetMs, 12000); expect(bulkB.keys, ['plex-public:42']); expect(bulkB['plex-public:42']!.title, 'Profile B'); expect(bulkB['plex-public:42']!.isWatched, isTrue); await cache.clearVolatile(); expect(await cache.getMetadata(scopeA, ratingKey), isNotNull); expect(await cache.getMetadata(scopeB, ratingKey), isNotNull); expect(await cache.isPinnedRatingKey(scopeA, ratingKey), isTrue); expect(await cache.isPinnedRatingKey(scopeB, ratingKey), isTrue); await cache.unpinForOffline(scopeA, ratingKey); await cache.deleteForItem(scopeA, ratingKey); expect(await cache.getMetadata(scopeA, ratingKey), isNull); expect(await cache.getMetadata(scopeB, ratingKey), isNotNull); expect(await cache.isPinnedRatingKey(scopeB, ratingKey), isTrue); }); }); }