Files
plezy/test/services/plex_client_metadata_test.dart
T
edde746 a9a7498991 fix(plex): remove comma-containing tags one request per tag
Plex's tag.tag- removes comma separated tags and has no escape syntax, so
removing a tag containing a literal comma ('Action, Comedy') split it at the
separator and could over-remove siblings named 'Action' or 'Comedy'.

Fields whose removals contain a comma now defer to one removal per request
after the main edit; every request restates the kept tags and the lock, so
the sequence converges regardless of order. Removals without commas keep the
exact single-request wire shape.
2026-08-21 19:23:45 +02:00

181 lines
6.8 KiB
Dart

import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/metadata_edit/plex_metadata_edit_adapter.dart';
import 'package:plezy/services/plex_api_cache.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
});
tearDown(() => database.close());
test('metadata preference request uses includePreferences and canonicalizes server values', () async {
Uri? requestedUri;
final client = testPlexClient(
handler: (request) async {
requestedUri = request.url;
return _metadataResponse([
{'id': 'episodeSort', 'value': 0},
{'id': 'autoDeletionItemPolicyUnwatchedLibrary', 'value': 3},
{'id': 'autoDeletionItemPolicyWatchedLibrary', 'value': 7},
{'id': 'flattenSeasons', 'value': 1},
{'id': 'showOrdering', 'value': 'tvdbAbsolute'},
{'id': 'languageOverride', 'value': 'fr-FR'},
{'id': 'useOriginalTitle', 'value': true},
{'id': 'audioLanguage', 'value': 'ja'},
{'id': 'subtitleLanguage', 'value': 'en'},
{'id': 'subtitleMode', 'value': 2},
]);
},
);
addTearDown(client.close);
final draft = await PlexMetadataEditAdapter(client).load(_show());
expect(requestedUri?.path, '/library/metadata/show-1');
expect(requestedUri?.queryParameters['includePreferences'], '1');
expect(draft.value<String>('originalTitle'), 'Original show title');
expect(draft.value<String>('pref:episodeSort'), '0');
expect(draft.value<String>('pref:autoDeletionItemPolicyUnwatchedLibrary'), '3');
expect(draft.value<String>('pref:autoDeletionItemPolicyWatchedLibrary'), '7');
expect(draft.value<String>('pref:flattenSeasons'), '1');
expect(draft.value<String>('pref:showOrdering'), 'tvdbAbsolute');
expect(draft.value<String>('pref:languageOverride'), 'fr-FR');
expect(draft.value<String>('pref:useOriginalTitle'), '1');
expect(draft.value<String>('pref:audioLanguage'), 'ja');
expect(draft.value<String>('pref:subtitleLanguage'), 'en');
expect(draft.value<String>('pref:subtitleMode'), '2');
});
test('missing preference rows remain unset instead of becoming fabricated defaults', () async {
final client = testPlexClient(
handler: (_) async => _metadataResponse([
{'id': 'episodeSort', 'value': 0},
]),
);
addTearDown(client.close);
final draft = await PlexMetadataEditAdapter(client).load(_show());
expect(draft.value<String>('pref:episodeSort'), '0');
expect(draft.value<String>('pref:flattenSeasons'), isNull);
expect(draft.value<String>('pref:showOrdering'), isNull);
expect(draft.value<String>('pref:languageOverride'), isNull);
expect(draft.value<String>('pref:useOriginalTitle'), isNull);
expect(draft.value<String>('pref:audioLanguage'), isNull);
expect(draft.value<String>('pref:subtitleMode'), isNull);
});
test('metadata preference request failures preserve the editable basic fields', () async {
final client = testPlexClient(handler: (_) async => http.Response('{}', 503));
addTearDown(client.close);
final draft = await PlexMetadataEditAdapter(client).load(_show());
expect(draft.value<String>('title'), 'Show');
expect(draft.value<String>('originalTitle'), 'Original show title');
expect(draft.value<String>('pref:episodeSort'), isNull);
});
test('removed tags are emitted single-encoded because the transport encodes exactly once', () async {
Uri? requestedUri;
final client = testPlexClient(
handler: (request) async {
requestedUri = request.url;
return http.Response('{}', 200, headers: const {'content-type': 'application/json'});
},
);
addTearDown(client.close);
final ok = await client.updateMetadata(
sectionId: 1,
ratingKey: 'show-1',
typeNumber: 2,
tagChanges: {
'genre': (current: ['Drama'], original: ['Drama', 'Science Fiction']),
},
);
expect(ok, isTrue);
expect(requestedUri, isNotNull);
// Decoding the emitted query once must yield the original tag. The old
// Uri.encodeComponent pre-pass double-encoded it ('Science%2520Fiction'
// on the wire), so the server deleted a tag that doesn't exist.
expect(requestedUri!.queryParameters['genre[].tag.tag-'], 'Science Fiction');
expect(requestedUri!.query, contains('Science%20Fiction'));
expect(requestedUri!.query, isNot(contains('%2520')));
});
test('a removed tag containing a comma travels one removal per request', () async {
final requestedUris = <Uri>[];
final client = testPlexClient(
handler: (request) async {
requestedUris.add(request.url);
return http.Response('{}', 200, headers: const {'content-type': 'application/json'});
},
);
addTearDown(client.close);
final ok = await client.updateMetadata(
sectionId: 1,
ratingKey: 'show-1',
typeNumber: 2,
tagChanges: {
'genre': (current: ['Drama'], original: ['Drama', 'Action, Comedy', 'Science Fiction']),
},
);
expect(ok, isTrue);
// Plex's tag.tag- removes "comma separated tags" with no escape, so a
// joined value would split 'Action, Comedy' and could over-remove tags
// named 'Action'/'Comedy'. The whole field defers to one removal per
// request; the main request still locks the field and restates keeps.
expect(requestedUris, hasLength(3));
expect(requestedUris[0].queryParameters.containsKey('genre[].tag.tag-'), isFalse);
expect(requestedUris[0].queryParameters['genre[0].tag.tag'], 'Drama');
expect(requestedUris[0].queryParameters['genre.locked'], '1');
expect(requestedUris[1].queryParameters['genre[].tag.tag-'], 'Action, Comedy');
expect(requestedUris[1].query, contains('Action%2C%20Comedy'));
expect(requestedUris[1].queryParameters['genre[0].tag.tag'], 'Drama');
expect(requestedUris[2].queryParameters['genre[].tag.tag-'], 'Science Fiction');
});
}
http.Response _metadataResponse(List<Object?> settings) {
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{'ratingKey': 'show-1', 'Setting': settings},
],
},
}),
200,
headers: const {'content-type': 'application/json'},
);
}
MediaItem _show() => testMediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'Show',
originalTitle: 'Original show title',
summary: 'Summary',
libraryId: '1',
);