Files
plezy/test/services/plex_api_cache_test.dart
T
2026-08-10 20:28:41 +02:00

479 lines
20 KiB
Dart

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<String, dynamic> 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,
},
],
},
};
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);
final mediaBrowserCache = ApiCache.forBackend(MediaBackend.jellyfin);
expect(identical(ApiCache.forBackend(MediaBackend.emby), mediaBrowserCache), isTrue);
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);
final replacement = JellyfinApiCache.instance;
expect(identical(ApiCache.forBackend(MediaBackend.jellyfin), replacement), isTrue);
expect(identical(ApiCache.forBackend(MediaBackend.emby), replacement), isTrue);
expect(identical(replacement.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');
});
});
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<void>.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);
});
});
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);
});
});
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/<id>', () 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'}));
});
});
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);
});
});
}