Files
plezy/test/services/jellyfin_api_cache_test.dart
T

384 lines
15 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/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<String, dynamic> 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<void> 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<void> putItemRow({
required ServerId serverId,
required String userId,
required String itemId,
Map<String, dynamic>? 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<String, dynamic>};
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);
}
});
});
}