Files
plezy/test/services/api_cache_watch_state_test.dart

196 lines
7.3 KiB
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/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<String, dynamic> container({int viewCount = 0, int viewOffset = 5000}) => {
'MediaContainer': {
'Metadata': [
{'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie', 'viewCount': viewCount, 'viewOffset': viewOffset},
],
},
};
Future<Map<String, dynamic>> readBack() async {
final cached = await PlexApiCache.instance.get(serverId, endpoint);
return (cached!['MediaContainer']['Metadata'] as List).first as Map<String, dynamic>;
}
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<int>());
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<String, dynamic> dto({int playCount = 0}) => {
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'UserData': {'Played': false, 'PlayCount': playCount, 'PlaybackPositionTicks': 990000},
};
Future<Map<String, dynamic>> readBack(String userId) async {
final cached = await JellyfinApiCache.instance.get(serverId, '/Users/$userId/Items/item-1');
return cached!['UserData'] as Map<String, dynamic>;
}
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);
});
});
}