diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 5667a61f..89d0f931 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -750,10 +750,11 @@ mixin MediaServerCacheMixin implements MediaServerClient { try { final response = await networkCall(); throwIfHttpError(response); + final parsed = parseResponse(response); if (cacheResponse) { await _putCacheResponse(cacheScope, cacheKey, response.data); } - return parseResponse(response); + return parsed; } catch (e) { if (shouldFallback != null && !shouldFallback(e)) rethrow; appLogger.w('Network request failed for $cacheKey, trying cache', error: e); @@ -784,10 +785,11 @@ mixin MediaServerCacheMixin implements MediaServerClient { if (isOfflineMode) return null; final response = await networkCall(); throwIfHttpError(response); + final parsed = parseResponse(response); if (cacheResponse) { await _putCacheResponse(cacheScope, cacheKey, response.data); } - return parseResponse(response); + return parsed; } Future _putCacheResponse(ServerId cacheScope, String cacheKey, dynamic data) async { diff --git a/test/media/media_server_client_cache_test.dart b/test/media/media_server_client_cache_test.dart index 0f36b0c8..a6084398 100644 --- a/test/media/media_server_client_cache_test.dart +++ b/test/media/media_server_client_cache_test.dart @@ -111,6 +111,25 @@ void main() { 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; @@ -214,6 +233,27 @@ void main() { 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); diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index 463045aa..2bae3e77 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -219,7 +219,7 @@ void main() { test('${api.name} propagates a valid JSON payload rejected by the item DTO', () async { final id = '${api.name}-cached-unmappable'; - await seedItem(id); + final seededResponse = await seedItem(id); var requestCount = 0; final client = makeClient((_) async { requestCount++; @@ -236,6 +236,13 @@ void main() { await expectLater(lookup(client, id, includeOnDeck: api.includeOnDeck), throwsA(isA())); expect(requestCount, 1); + expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse); + + client.setOfflineMode(true); + final offlineResult = await lookup(client, id, includeOnDeck: api.includeOnDeck); + expect(offlineResult.item?.id, id); + expect(offlineResult.item?.title, 'Cached item'); + expect(requestCount, 1); }); test('${api.name} uses profile-scoped cache without HTTP while explicitly offline', () async {