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/ids.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; /// Pins the per-backend `applyWatchState` cache mutations. The two /// implementations are duplicated by design (see ApiCache.applyWatchState's /// drift-discipline note) — these tests are the enforcement arm: unit /// conversions (ms ↔ 100-ns ticks, epoch-seconds ↔ ISO-8601) and the /// intentional asymmetries fail here instead of drifting silently. void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); JellyfinApiCache.initialize(db); }); tearDown(() async { await db.close(); }); group('PlexApiCache.applyWatchState', () { final serverId = ServerId('plex-srv'); const endpoint = '/library/metadata/movie-1'; Map container({int viewCount = 0, int viewOffset = 5000}) => { 'MediaContainer': { 'Metadata': [ {'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie', 'viewCount': viewCount, 'viewOffset': viewOffset}, ], }, }; Future> readBack() async { final cached = await PlexApiCache.instance.get(serverId, endpoint); return (cached!['MediaContainer']['Metadata'] as List).first as Map; } test('watched flip sets viewCount, zeroes viewOffset, stamps lastViewedAt', () async { await PlexApiCache.instance.put(serverId, endpoint, container()); await PlexApiCache.instance.applyWatchState(serverId: serverId, itemId: 'movie-1', isWatched: true); final json = await readBack(); expect(json['viewCount'], 1); expect(json['viewOffset'], 0); expect(json['lastViewedAt'], isA()); expect(json['lastViewedAt'] as int, greaterThan(0)); }); test('watched flip preserves an existing viewCount', () async { await PlexApiCache.instance.put(serverId, endpoint, container(viewCount: 3)); await PlexApiCache.instance.applyWatchState(serverId: serverId, itemId: 'movie-1', isWatched: true); expect((await readBack())['viewCount'], 3); }); test('unwatched flip zeroes viewCount/viewOffset and leaves lastViewedAt alone', () async { await PlexApiCache.instance.put(serverId, endpoint, container(viewCount: 2)); await PlexApiCache.instance.applyWatchState(serverId: serverId, itemId: 'movie-1', isWatched: false); final json = await readBack(); expect(json['viewCount'], 0); expect(json['viewOffset'], 0); expect(json.containsKey('lastViewedAt'), isFalse); }); test('richer snapshot fields overwrite the flip defaults', () async { await PlexApiCache.instance.put(serverId, endpoint, container()); await PlexApiCache.instance.applyWatchState( serverId: serverId, itemId: 'movie-1', isWatched: true, viewOffsetMs: 123456, lastViewedAt: 1700000000, viewedLeafCount: 7, ); final json = await readBack(); // Plex stores epoch-seconds and flat fields; viewedLeafCount is stored // directly (unlike Jellyfin, which ignores it — see its test below). expect(json['viewOffset'], 123456); expect(json['lastViewedAt'], 1700000000); expect(json['viewedLeafCount'], 7); }); test('no cached row is a silent no-op', () async { await PlexApiCache.instance.applyWatchState(serverId: serverId, itemId: 'missing', isWatched: true); }); }); group('JellyfinApiCache.applyWatchState', () { final serverId = ServerId('jf-srv'); Map dto({int playCount = 0}) => { 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'UserData': {'Played': false, 'PlayCount': playCount, 'PlaybackPositionTicks': 990000}, }; Future> readBack(String userId) async { final cached = await JellyfinApiCache.instance.get(serverId, '/Users/$userId/Items/item-1'); return cached!['UserData'] as Map; } test('skips ambiguous bare scope when multiple users cache the same item', () async { await JellyfinApiCache.instance.put(serverId, '/Users/user-a/Items/item-1', dto()); await JellyfinApiCache.instance.put(serverId, '/Users/user-b/Items/item-1', dto()); await JellyfinApiCache.instance.applyWatchState(serverId: serverId, itemId: 'item-1', isWatched: true); expect((await readBack('user-a'))['Played'], isFalse); expect((await readBack('user-b'))['Played'], isFalse); }); test('converts viewOffsetMs to 100-ns ticks', () async { await JellyfinApiCache.instance.put(serverId, '/Users/user-a/Items/item-1', dto()); await JellyfinApiCache.instance.applyWatchState( serverId: serverId, itemId: 'item-1', isWatched: true, viewOffsetMs: 5000, ); expect((await readBack('user-a'))['PlaybackPositionTicks'], 5000 * 10000); }); test('converts epoch-seconds lastViewedAt to ISO-8601 LastPlayedDate', () async { await JellyfinApiCache.instance.put(serverId, '/Users/user-a/Items/item-1', dto()); await JellyfinApiCache.instance.applyWatchState( serverId: serverId, itemId: 'item-1', isWatched: true, lastViewedAt: 1700000000, ); final lastPlayed = (await readBack('user-a'))['LastPlayedDate'] as String; expect(DateTime.parse(lastPlayed), DateTime.fromMillisecondsSinceEpoch(1700000000 * 1000, isUtc: true)); }); test('watched flip preserves an existing PlayCount; unwatched zeroes it', () async { await JellyfinApiCache.instance.put(serverId, '/Users/user-a/Items/item-1', dto(playCount: 4)); await JellyfinApiCache.instance.applyWatchState(serverId: serverId, itemId: 'item-1', isWatched: true); expect((await readBack('user-a'))['PlayCount'], 4); await JellyfinApiCache.instance.applyWatchState(serverId: serverId, itemId: 'item-1', isWatched: false); final userData = await readBack('user-a'); expect(userData['PlayCount'], 0); expect(userData['Played'], isFalse); }); test('viewedLeafCount is intentionally ignored (UnplayedItemCount stays server-computed)', () async { await JellyfinApiCache.instance.put(serverId, '/Users/user-a/Items/item-1', dto()); await JellyfinApiCache.instance.applyWatchState( serverId: serverId, itemId: 'item-1', isWatched: true, viewedLeafCount: 9, ); final userData = await readBack('user-a'); expect(userData.containsKey('UnplayedItemCount'), isFalse); expect(userData.values, isNot(contains(9))); }); test('malformed cached rows are skipped without throwing', () async { await db .into(db.apiCache) .insertOnConflictUpdate( ApiCacheCompanion( cacheKey: const Value('jf-srv:/Users/user-a/Items/item-1'), data: const Value('not json'), cachedAt: Value(DateTime.now()), ), ); await JellyfinApiCache.instance.applyWatchState(serverId: serverId, itemId: 'item-1', isWatched: true); }); }); }