import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/services/api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/media_server_http_client.dart'; class _CacheClient with MediaServerCacheMixin implements MediaServerClient { _CacheClient(this.cache); @override final ApiCache cache; @override ServerId get serverId => ServerId('cache-server'); @override MediaBackend get backend => MediaBackend.plex; bool offline = false; @override bool get isOfflineMode => offline; @override void setOfflineMode(bool value) => offline = value; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } void main() { late AppDatabase database; late PlexApiCache cache; late _CacheClient client; setUp(() { database = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(database); cache = PlexApiCache.instance; client = _CacheClient(cache); }); tearDown(() async { await database.close(); }); test('cache-first rejects decoded 401 and 500 before parsing or caching', () async { for (final status in [401, 500]) { var networkCalls = 0; var parserCalls = 0; final key = '/metadata/status-$status'; await expectLater( client.fetchWithCacheFirst( cacheScope: client.serverId, cacheKey: key, networkCall: () async { networkCalls++; return MediaServerResponse( statusCode: status, data: { 'MediaContainer': { 'Metadata': [ {'ratingKey': 'must-not-parse'}, ], }, }, headers: const {}, ); }, parseCache: (_) => 'cached', parseResponse: (_) { parserCalls++; return 'parsed'; }, ), throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), ); expect(networkCalls, 1); expect(parserCalls, 0); expect(await cache.get(client.serverId, key), isNull); } }); test('cache-first validates status even when response caching is disabled', () async { var parserCalls = 0; await expectLater( client.fetchWithCacheFirst( cacheScope: client.serverId, cacheKey: '/metadata/no-cache', cacheResponse: false, networkCall: () async => MediaServerResponse(statusCode: 500, data: const {'mustNotParse': true}, headers: const {}), parseCache: (_) => 'cached', parseResponse: (_) { parserCalls++; return 'parsed'; }, ), throwsA(isA().having((error) => error.statusCode, 'statusCode', 500)), ); expect(parserCalls, 0); expect(await cache.get(client.serverId, '/metadata/no-cache'), isNull); }); test('cache-first leaves a rejected response uncached', () async { const key = '/metadata/rejected-response'; const body = {'value': 'invalid'}; final parseError = FormatException('invalid metadata'); await expectLater( client.fetchWithCacheFirst( cacheScope: client.serverId, cacheKey: key, networkCall: () async => MediaServerResponse(statusCode: 200, data: body, headers: const {}), parseCache: (_) => 'cached', parseResponse: (_) => throw parseError, ), throwsA(same(parseError)), ); expect(await cache.get(client.serverId, key), isNull); }); test('successful miss is parsed and cached exactly once', () async { var networkCalls = 0; var parserCalls = 0; const body = { 'MediaContainer': { 'Metadata': [ {'ratingKey': '42'}, ], }, }; final value = await client.fetchWithCacheFirst( cacheScope: client.serverId, cacheKey: '/metadata/success', networkCall: () async { networkCalls++; return MediaServerResponse(statusCode: 200, data: body, headers: const {}); }, parseCache: (_) => 'cached', parseResponse: (response) { parserCalls++; return (response.data as Map)['MediaContainer'].toString(); }, ); expect(value, contains('ratingKey')); expect(networkCalls, 1); expect(parserCalls, 1); expect(await cache.get(client.serverId, '/metadata/success'), body); }); test('prepopulated cache wins without network or response parsing', () async { const body = {'cached': true}; await cache.put(client.serverId, '/metadata/cached', body); var responseParserCalls = 0; final value = await client.fetchWithCacheFirst( cacheScope: client.serverId, cacheKey: '/metadata/cached', networkCall: () => fail('Network must not be called for a cache hit'), parseCache: (cached) => (cached as Map)['cached'].toString(), parseResponse: (_) { responseParserCalls++; return 'network'; }, ); expect(value, 'true'); expect(responseParserCalls, 0); }); test('offline cache miss returns null without network or parsers', () async { client.setOfflineMode(true); var cacheParserCalls = 0; var responseParserCalls = 0; final value = await client.fetchWithCacheFirst( cacheScope: client.serverId, cacheKey: '/metadata/offline-miss', networkCall: () => fail('Network must not be called while offline'), parseCache: (_) { cacheParserCalls++; return 'cached'; }, parseResponse: (_) { responseParserCalls++; return 'network'; }, ); expect(value, isNull); expect(cacheParserCalls, 0); expect(responseParserCalls, 0); }); group('fetchWithCacheFallback error selection', () { const cachedBody = {'value': 'cached'}; test('omitted selector preserves fallback on an HTTP 500', () async { const key = '/metadata/default-fallback'; await cache.put(client.serverId, key, cachedBody); var cacheParserCalls = 0; var responseParserCalls = 0; final value = await client.fetchWithCacheFallback( cacheKey: key, networkCall: () async => MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}), parseCache: (cached) { cacheParserCalls++; return (cached as Map)['value'] as String; }, parseResponse: (_) { responseParserCalls++; return 'network'; }, ); expect(value, 'cached'); expect(cacheParserCalls, 1); expect(responseParserCalls, 0); }); test('omitted selector serves the previous cache after response parsing fails', () async { const key = '/metadata/parser-fallback'; await cache.put(client.serverId, key, cachedBody); var cacheParserCalls = 0; final value = await client.fetchWithCacheFallback( cacheKey: key, networkCall: () async => MediaServerResponse(statusCode: 200, data: const {'value': 'invalid'}, headers: const {}), parseCache: (cached) { cacheParserCalls++; return (cached as Map)['value'] as String; }, parseResponse: (_) => throw const FormatException('invalid metadata'), ); expect(value, 'cached'); expect(cacheParserCalls, 1); expect(await cache.get(client.serverId, key), cachedBody); }); test('rejecting selector rethrows an HTTP status without reading cached data', () async { const key = '/metadata/rejected-fallback'; await cache.put(client.serverId, key, cachedBody); var cacheParserCalls = 0; var responseParserCalls = 0; await expectLater( client.fetchWithCacheFallback( cacheKey: key, networkCall: () async => MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}), shouldFallback: (error) => error is MediaServerHttpException && error.isTransient, parseCache: (_) { cacheParserCalls++; return 'cached'; }, parseResponse: (_) { responseParserCalls++; return 'network'; }, ), throwsA(isA().having((error) => error.statusCode, 'statusCode', 500)), ); expect(cacheParserCalls, 0); expect(responseParserCalls, 0); expect(await cache.get(client.serverId, key), cachedBody); }); test('accepting selector serves cache after a transient transport failure', () async { const key = '/metadata/transient-fallback'; await cache.put(client.serverId, key, cachedBody); final transient = MediaServerHttpException( type: MediaServerHttpErrorType.connectionTimeout, message: 'timed out', ); Object? selectedError; var cacheParserCalls = 0; var responseParserCalls = 0; final value = await client.fetchWithCacheFallback( cacheKey: key, networkCall: () => Future.error(transient), shouldFallback: (error) { selectedError = error; return error is MediaServerHttpException && error.isTransient; }, parseCache: (cached) { cacheParserCalls++; return (cached as Map)['value'] as String; }, parseResponse: (_) { responseParserCalls++; return 'network'; }, ); expect(value, 'cached'); expect(identical(selectedError, transient), isTrue); expect(cacheParserCalls, 1); expect(responseParserCalls, 0); }); }); }