import 'package:drift/native.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/sync_rule_executor.dart'; import '../test_helpers/prefs.dart'; JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( id: 'jf-machine/$userId', baseUrl: 'https://jf.example.com', serverName: 'Shared JF', serverMachineId: 'jf-machine', userId: userId, userName: userId, accessToken: 'token-$userId', deviceId: 'device', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); void main() { setUp(resetSharedPreferencesForTest); test('profile-scoped Jellyfin sync rule executes through active client', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final pathsByUser = >{'user-a': [], 'user-b': []}; JellyfinClient clientFor(String userId) { return JellyfinClient.forTesting( connection: _jellyfinConnection(userId), httpClient: MockClient((request) async { pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/show-1') { return http.Response( '{"Id":"show-1","Type":"Series","Name":"Show $userId","RecursiveItemCount":1}', 200, headers: {'content-type': 'application/json'}, ); } if (request.method == 'GET' && request.url.path == '/Items') { return http.Response( '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }), ); } final userA = clientFor('user-a'); final userB = clientFor('user-b'); addTearDown(userA.close); addTearDown(userB.close); manager.debugRegisterJellyfinClientForTesting(userB, online: false); manager.debugRegisterJellyfinClientForTesting(userA); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); await db.insertWatchAction( serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-b', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); await db.insertWatchAction( profileId: 'profile-b', serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); final queued = <({MediaItem item, MediaServerClient client})>[]; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add((item: item, client: client)); return true; }, isOffline: false, force: true, ); expect(results.single.queuedCount, 1); expect(queued.single.client, same(userA)); expect(pathsByUser['user-a']!.where((p) => p.startsWith('GET /Items?')), isNotEmpty); expect(pathsByUser['user-b'], isEmpty); }); test('profile-scoped sync rule excludes only the active profile local watched actions', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final paths = []; final userA = JellyfinClient.forTesting( connection: _jellyfinConnection('user-a'), httpClient: MockClient((request) async { paths.add('${request.method} ${request.url.path}?${request.url.query}'); if (request.method == 'GET' && request.url.path == '/Users/user-a/Items/show-1') { return http.Response( '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}', 200, headers: {'content-type': 'application/json'}, ); } if (request.method == 'GET' && request.url.path == '/Items') { return http.Response( '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }), ); addTearDown(userA.close); manager.debugRegisterJellyfinClientForTesting(userA); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); await db.insertWatchAction( profileId: 'profile-a', serverId: ServerId('jf-machine'), clientScopeId: 'jf-machine/user-a', ratingKey: 'ep-1', actionType: OfflineActionType.watched.id, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results, isEmpty); expect(queued, isEmpty); expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); }); test('profile-scoped Jellyfin sync rule does not execute for another profile', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final paths = []; final userB = JellyfinClient.forTesting( connection: _jellyfinConnection('user-b'), httpClient: MockClient((request) async { paths.add('${request.method} ${request.url.path}?${request.url.query}'); return http.Response('not found', 404); }), ); addTearDown(userB.close); manager.debugRegisterJellyfinClientForTesting(userB); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-a|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-b', serverManager: manager, downloads: const {}, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true, isOffline: false, force: true, ); expect(results, isEmpty); expect(paths, isEmpty); }); test('profile-scoped Jellyfin sync rule counts shared public downloads as already present', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final paths = []; final userB = JellyfinClient.forTesting( connection: _jellyfinConnection('user-b'), httpClient: MockClient((request) async { paths.add('${request.method} ${request.url.path}?${request.url.query}'); if (request.method == 'GET' && request.url.path == '/Users/user-b/Items/show-1') { return http.Response( '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}', 200, headers: {'content-type': 'application/json'}, ); } if (request.method == 'GET' && request.url.path == '/Items') { return http.Response( '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }), ); addTearDown(userB.close); manager.debugRegisterJellyfinClientForTesting(userB); await db.insertSyncRule( profileId: 'profile-b', serverId: ServerId('jf-machine'), ratingKey: 'show-1', globalKey: 'profile-b|jf-machine:show-1', targetType: 'show', episodeCount: 1, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-b', serverManager: manager, downloads: const { 'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), }, metadata: const {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results, isEmpty); expect(queued, isEmpty); expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); }); test('collection sync rule pages through collection API instead of metadata children', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); await db.close(); }); final client = _CollectionPagingClient(); manager.debugRegisterClientForTesting(client); const ruleKey = 'profile-a|plex-machine:collection-1'; final collection = MediaItem( id: 'collection-1', backend: MediaBackend.plex, kind: MediaKind.collection, title: 'Collection', serverId: 'plex-machine', ); await db.insertSyncRule( profileId: 'profile-a', serverId: ServerId('plex-machine'), ratingKey: 'collection-1', globalKey: ruleKey, targetType: 'collection', episodeCount: 0, downloadFilter: SyncRuleFilter.all, ); final queued = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: {ruleKey: collection}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; }, isOffline: false, force: true, ); expect(results.single.queuedCount, 1); expect(queued.single.id, 'movie-1'); expect(client.collectionPageCalls, [(start: 0, size: 100)]); expect(client.fetchChildrenCalled, isFalse); }); } class _CollectionPagingClient implements MediaServerClient { bool fetchChildrenCalled = false; final collectionPageCalls = <({int? start, int? size})>[]; @override ServerId get serverId => ServerId('plex-machine'); @override String? get serverName => 'Plex'; @override MediaBackend get backend => MediaBackend.plex; @override ServerCapabilities get capabilities => ServerCapabilities.plex; @override bool get isOfflineMode => false; @override void close() {} @override Future fetchItem(String id) async => null; @override Future> fetchChildren(String parentId) async { fetchChildrenCalled = true; throw StateError('collection rules must not use fetchChildren'); } @override Future> fetchCollectionPage( String collectionId, { int? start, int? size, abort, String? libraryId, String? libraryTitle, }) async { collectionPageCalls.add((start: start, size: size)); expect(collectionId, 'collection-1'); return LibraryPage( items: [MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')], totalCount: 1, offset: start ?? 0, ); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }