fix(runtime): harden application service boundaries
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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<String>(
|
||||
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<MediaServerHttpException>().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<String>(
|
||||
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<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 500)),
|
||||
);
|
||||
|
||||
expect(parserCalls, 0);
|
||||
expect(await cache.get(client.serverId, '/metadata/no-cache'), 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<String>(
|
||||
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<String, dynamic>)['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<String>(
|
||||
cacheScope: client.serverId,
|
||||
cacheKey: '/metadata/cached',
|
||||
networkCall: () => fail('Network must not be called for a cache hit'),
|
||||
parseCache: (cached) => (cached as Map<String, dynamic>)['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<String>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_version.dart';
|
||||
|
||||
void main() {
|
||||
MediaVersion version(String resolution, String codec, String container, {String id = ''}) {
|
||||
return MediaVersion(id: id, videoResolution: resolution, videoCodec: codec, container: container);
|
||||
}
|
||||
|
||||
group('MediaVersion.findMatchingIndex', () {
|
||||
test('exact match globally outranks an earlier resolution+codec match', () {
|
||||
final versions = [version('1080', 'h264', 'mp4'), version('4k', 'hevc', 'mkv')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:mkv', '4k:hevc:mkv'}), 1);
|
||||
});
|
||||
|
||||
test('exact match globally outranks an earlier resolution-only match', () {
|
||||
final versions = [version('1080', 'vp9', 'mp4'), version('4k', 'hevc', 'mkv')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:avi', '4k:hevc:mkv'}), 1);
|
||||
});
|
||||
|
||||
test('resolution+codec globally outranks an earlier resolution-only match', () {
|
||||
final versions = [version('1080', 'vp9', 'mp4'), version('4k', 'hevc', 'mp4')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:avi', '4k:hevc:mkv'}), 1);
|
||||
});
|
||||
|
||||
test('skips malformed signatures without blocking a later valid exact match', () {
|
||||
final versions = [version('1080', 'h264', 'mkv')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'malformed', '1080:h264:mkv'}), 0);
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'malformed', 'also:malformed'}), isNull);
|
||||
});
|
||||
|
||||
test('earlier accepted signature wins a same-tier tie even with a later candidate', () {
|
||||
final versions = [version('4k', 'hevc', 'mp4'), version('1080', 'h264', 'mp4')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:mkv', '4k:hevc:mkv'}), 1);
|
||||
});
|
||||
|
||||
test('retains valid three-field signatures with empty fields', () {
|
||||
const versions = [MediaVersion(id: 'empty')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'::'}), 0);
|
||||
});
|
||||
|
||||
test('earlier candidate wins when one signature has multiple same-tier matches', () {
|
||||
final versions = [version('1080', 'h264', 'mp4'), version('1080', 'h264', 'avi')];
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:mkv'}), 0);
|
||||
});
|
||||
|
||||
test('singleton signatures retain exact then codec then resolution priority', () {
|
||||
final resolutionOnly = version('1080', 'vp9', 'mp4');
|
||||
final resolutionAndCodec = version('1080', 'h264', 'mp4');
|
||||
final exact = version('1080', 'h264', 'mkv');
|
||||
const accepted = {'1080:h264:mkv'};
|
||||
|
||||
expect(MediaVersion.findMatchingIndex([resolutionOnly, resolutionAndCodec, exact], accepted), 2);
|
||||
expect(MediaVersion.findMatchingIndex([resolutionOnly, resolutionAndCodec], accepted), 1);
|
||||
expect(MediaVersion.findMatchingIndex([resolutionOnly], accepted), 0);
|
||||
});
|
||||
|
||||
test('returns null for empty candidates or accepted signatures', () {
|
||||
final candidate = version('1080', 'h264', 'mkv');
|
||||
|
||||
expect(MediaVersion.findMatchingIndex(const [], {'1080:h264:mkv'}), isNull);
|
||||
expect(MediaVersion.findMatchingIndex([candidate], const {}), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user