From 96510f8aacfd66e14917a3548795c5aae7d9a205 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:23:34 +0200 Subject: [PATCH] fix(plex): tolerate account-API schema drift in profile and switch parsing Around July 3 plex.tv started returning the profile language-list fields (defaultAudioLanguages, defaultSubtitleLanguages, mediaReviewsLanguages) as comma-separated strings instead of arrays. The generated cast threw on the successful 201 /switch response, dropping the freshly minted Home user token: every rebind failed, the binder retried a /switch mint every 1-2s, and the app sat permanently in offline mode even after re-signing in. Accounts without language prefs set were unaffected, which is why the breakage looked sporadic. Parse the language lists with a CSV-aware coercion, and make UserSwitchResponse.fromJson strict only about authToken: decorative fields now coerce tolerantly and a broken profile blob falls back to defaults, so account-API drift can never brick token minting again. close #1488 --- lib/models/plex/plex_user_profile.dart | 48 +++++-- lib/models/plex/plex_user_profile.g.dart | 35 ++--- lib/models/user_switch_response.dart | 80 +++++++---- lib/utils/json_utils.dart | 19 +++ test/models/plex_user_profile_test.dart | 92 ++++++++++++ test/models/user_switch_response_test.dart | 111 +++++++++++++++ test/profiles/plex_home_switch_test.dart | 155 +++++++++++++++++++++ test/services/plex_auth_service_test.dart | 33 +++++ test/utils/json_utils_test.dart | 30 ++++ 9 files changed, 546 insertions(+), 57 deletions(-) create mode 100644 test/models/plex_user_profile_test.dart create mode 100644 test/models/user_switch_response_test.dart create mode 100644 test/profiles/plex_home_switch_test.dart diff --git a/lib/models/plex/plex_user_profile.dart b/lib/models/plex/plex_user_profile.dart index acb03cee..1487d1a2 100644 --- a/lib/models/plex/plex_user_profile.dart +++ b/lib/models/plex/plex_user_profile.dart @@ -1,36 +1,47 @@ import 'package:json_annotation/json_annotation.dart'; import '../../media/media_server_user_profile.dart'; +import '../../utils/json_utils.dart'; part 'plex_user_profile.g.dart'; /// Represents a Plex user's profile preferences /// Fetched from https://clients.plex.tv/api/v2/user +/// +/// Every field parses tolerantly: the account API drifts (~July 2026 the +/// language-list fields switched from arrays to CSV strings, #1488), and a +/// profile blob must never fail to parse — token minting embeds it (see +/// UserSwitchResponse.fromJson). @JsonSerializable() class PlexUserProfile implements MediaServerUserProfile { - @JsonKey(defaultValue: true) + @JsonKey(fromJson: _boolOrTrue) @override final bool autoSelectAudio; - @JsonKey(defaultValue: 0) + @JsonKey(fromJson: _intOr0) final int defaultAudioAccessibility; + @JsonKey(fromJson: _flexibleLanguage) @override final String? defaultAudioLanguage; + @JsonKey(fromJson: flexibleCsvStringList) @override final List? defaultAudioLanguages; + @JsonKey(fromJson: _flexibleLanguage) @override final String? defaultSubtitleLanguage; + @JsonKey(fromJson: flexibleCsvStringList) @override final List? defaultSubtitleLanguages; - @JsonKey(defaultValue: 0) + @JsonKey(fromJson: _intOr0) final int autoSelectSubtitle; - @JsonKey(defaultValue: 0) + @JsonKey(fromJson: _intOr0) final int defaultSubtitleAccessibility; - @JsonKey(defaultValue: 1) + @JsonKey(fromJson: _intOr1) final int defaultSubtitleForced; - @JsonKey(defaultValue: 1) + @JsonKey(fromJson: _intOr1) final int watchedIndicator; - @JsonKey(defaultValue: 0) + @JsonKey(fromJson: _intOr0) final int mediaReviewsVisibility; + @JsonKey(fromJson: flexibleCsvStringList) final List? mediaReviewsLanguages; @override @@ -51,10 +62,31 @@ class PlexUserProfile implements MediaServerUserProfile { this.mediaReviewsLanguages, }); + /// Neutral fallback matching the generated defaults — used when the account + /// API returns a profile blob that cannot be parsed at all (schema drift + /// must never break token minting, see UserSwitchResponse.fromJson). + factory PlexUserProfile.defaults() => PlexUserProfile( + autoSelectAudio: true, + defaultAudioAccessibility: 0, + autoSelectSubtitle: 0, + defaultSubtitleAccessibility: 0, + defaultSubtitleForced: 1, + watchedIndicator: 1, + mediaReviewsVisibility: 0, + ); + factory PlexUserProfile.fromJson(Map json) { - final profile = json['profile'] as Map? ?? json; + final envelope = json['profile']; + final profile = envelope is Map ? envelope : json; return _$PlexUserProfileFromJson(profile); } Map toJson() => {'profile': _$PlexUserProfileToJson(this)}; } + +/// Singular language fields tolerate the inverse drift (array/CSV → first entry). +String? _flexibleLanguage(Object? v) => flexibleCsvStringList(v)?.first; + +bool _boolOrTrue(Object? v) => flexibleBoolNullable(v) ?? true; +int _intOr0(Object? v) => flexibleInt(v) ?? 0; +int _intOr1(Object? v) => flexibleInt(v) ?? 1; diff --git a/lib/models/plex/plex_user_profile.g.dart b/lib/models/plex/plex_user_profile.g.dart index bdba7d4a..fafe0f7d 100644 --- a/lib/models/plex/plex_user_profile.g.dart +++ b/lib/models/plex/plex_user_profile.g.dart @@ -9,27 +9,20 @@ part of 'plex_user_profile.dart'; PlexUserProfile _$PlexUserProfileFromJson( Map json, ) => PlexUserProfile( - autoSelectAudio: json['autoSelectAudio'] as bool? ?? true, - defaultAudioAccessibility: - (json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0, - defaultAudioLanguage: json['defaultAudioLanguage'] as String?, - defaultAudioLanguages: (json['defaultAudioLanguages'] as List?) - ?.map((e) => e as String) - .toList(), - defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?, - defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List?) - ?.map((e) => e as String) - .toList(), - autoSelectSubtitle: (json['autoSelectSubtitle'] as num?)?.toInt() ?? 0, - defaultSubtitleAccessibility: - (json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0, - defaultSubtitleForced: (json['defaultSubtitleForced'] as num?)?.toInt() ?? 1, - watchedIndicator: (json['watchedIndicator'] as num?)?.toInt() ?? 1, - mediaReviewsVisibility: - (json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0, - mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List?) - ?.map((e) => e as String) - .toList(), + autoSelectAudio: _boolOrTrue(json['autoSelectAudio']), + defaultAudioAccessibility: _intOr0(json['defaultAudioAccessibility']), + defaultAudioLanguage: _flexibleLanguage(json['defaultAudioLanguage']), + defaultAudioLanguages: flexibleCsvStringList(json['defaultAudioLanguages']), + defaultSubtitleLanguage: _flexibleLanguage(json['defaultSubtitleLanguage']), + defaultSubtitleLanguages: flexibleCsvStringList( + json['defaultSubtitleLanguages'], + ), + autoSelectSubtitle: _intOr0(json['autoSelectSubtitle']), + defaultSubtitleAccessibility: _intOr0(json['defaultSubtitleAccessibility']), + defaultSubtitleForced: _intOr1(json['defaultSubtitleForced']), + watchedIndicator: _intOr1(json['watchedIndicator']), + mediaReviewsVisibility: _intOr0(json['mediaReviewsVisibility']), + mediaReviewsLanguages: flexibleCsvStringList(json['mediaReviewsLanguages']), ); Map _$PlexUserProfileToJson(PlexUserProfile instance) => diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart index 66b80502..25f8ad83 100644 --- a/lib/models/user_switch_response.dart +++ b/lib/models/user_switch_response.dart @@ -1,3 +1,5 @@ +import '../utils/app_logger.dart'; +import '../utils/json_utils.dart'; import 'plex/plex_user_profile.dart'; class UserSwitchResponse { @@ -61,36 +63,58 @@ class UserSwitchResponse { this.attributionPartner, }); + /// INVARIANT (#1488): a successful token mint must never be lost to parsing + /// of decorative fields. `authToken` is the only field any caller consumes + /// (see plex_home_switch.dart) — it alone parses strictly; every other + /// field tolerates missing/wrong-typed values with sane defaults. Plex has + /// changed field shapes on this endpoint before (July 2026: profile + /// language lists became CSV strings), and each drift used to brick token + /// minting outright. factory UserSwitchResponse.fromJson(Map json) { + final authToken = json['authToken']; + if (authToken is! String || authToken.isEmpty) { + throw const FormatException('Plex /switch response has no usable authToken'); + } + + PlexUserProfile profile; + try { + profile = PlexUserProfile.fromJson(json); + } catch (e, st) { + appLogger.w('UserSwitchResponse: profile blob failed to parse; using defaults', error: e, stackTrace: st); + profile = PlexUserProfile.defaults(); + } + + String? optString(String key) => json[key]?.toString(); + return UserSwitchResponse( - id: json['id'] as int, - uuid: json['uuid'] as String, - username: json['username'] as String? ?? '', - title: json['title'] as String, - email: json['email'] as String? ?? '', - friendlyName: json['friendlyName'] as String?, - locale: json['locale'] as String?, - confirmed: json['confirmed'] as bool, - joinedAt: json['joinedAt'] as int, - emailOnlyAuth: json['emailOnlyAuth'] as bool, - hasPassword: json['hasPassword'] as bool, - protected: json['protected'] as bool, - thumb: json['thumb'] as String, - authToken: json['authToken'] as String, - mailingListActive: json['mailingListActive'] as bool?, - scrobbleTypes: json['scrobbleTypes'] as String? ?? '', - country: json['country'] as String? ?? '', - restricted: json['restricted'] as bool, - anonymous: json['anonymous'] as bool?, - home: json['home'] as bool, - guest: json['guest'] as bool, - homeSize: json['homeSize'] as int, - homeAdmin: json['homeAdmin'] as bool, - maxHomeSize: json['maxHomeSize'] as int, - profile: PlexUserProfile.fromJson(json), - twoFactorEnabled: json['twoFactorEnabled'] as bool, - backupCodesCreated: json['backupCodesCreated'] as bool, - attributionPartner: json['attributionPartner'] as String?, + id: flexibleInt(json['id']) ?? 0, + uuid: optString('uuid') ?? '', + username: optString('username') ?? '', + title: optString('title') ?? '', + email: optString('email') ?? '', + friendlyName: optString('friendlyName'), + locale: optString('locale'), + confirmed: flexibleBool(json['confirmed']), + joinedAt: flexibleInt(json['joinedAt']) ?? 0, + emailOnlyAuth: flexibleBool(json['emailOnlyAuth']), + hasPassword: flexibleBool(json['hasPassword']), + protected: flexibleBool(json['protected']), + thumb: optString('thumb') ?? '', + authToken: authToken, + mailingListActive: flexibleBoolNullable(json['mailingListActive']), + scrobbleTypes: optString('scrobbleTypes') ?? '', + country: optString('country') ?? '', + restricted: flexibleBool(json['restricted']), + anonymous: flexibleBoolNullable(json['anonymous']), + home: flexibleBool(json['home']), + guest: flexibleBool(json['guest']), + homeSize: flexibleInt(json['homeSize']) ?? 1, + homeAdmin: flexibleBool(json['homeAdmin']), + maxHomeSize: flexibleInt(json['maxHomeSize']) ?? 1, + profile: profile, + twoFactorEnabled: flexibleBool(json['twoFactorEnabled']), + backupCodesCreated: flexibleBool(json['backupCodesCreated']), + attributionPartner: optString('attributionPartner'), ); } diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index d3e0b1f3..43b81311 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -63,6 +63,25 @@ List? flexibleStringList(Object? v) { return result.isEmpty ? null : result; } +/// Coerce a comma-separated String ("en,sv"), a bare String, a List of +/// Strings, or null into `List?`. Since ~July 2026 the Plex account +/// API (clients.plex.tv `/api/v2/user` and `/home/users/{uuid}/switch`) +/// returns the profile language-list fields as CSV strings instead of arrays +/// (#1488) — this tolerates both shapes. Parts are trimmed and empties +/// dropped; an empty result (or null input) yields `null`. CSV-splitting +/// sibling of [flexibleStringList], kept separate so that caller's strings +/// (Fribb IMDb ids) stay verbatim. +List? flexibleCsvStringList(Object? v) { + final strings = flexibleStringList(v); + if (strings == null) return null; + final result = [ + for (final s in strings) + for (final part in s.split(',')) + if (part.trim().isNotEmpty) part.trim(), + ]; + return result.isEmpty ? null : result; +} + List? stringListFromRaw(Object? raw, {String? mapKey, bool stringify = false, bool nullIfEmpty = false}) { if (raw is! List) return null; final result = []; diff --git a/test/models/plex_user_profile_test.dart b/test/models/plex_user_profile_test.dart new file mode 100644 index 00000000..be75576c --- /dev/null +++ b/test/models/plex_user_profile_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex/plex_user_profile.dart'; + +void main() { + group('PlexUserProfile.fromJson', () { + test('parses legacy array shape for language lists', () { + final profile = PlexUserProfile.fromJson({ + 'defaultAudioLanguages': ['en', 'sv'], + 'defaultSubtitleLanguages': ['sv'], + 'mediaReviewsLanguages': ['en'], + }); + + expect(profile.defaultAudioLanguages, ['en', 'sv']); + expect(profile.defaultSubtitleLanguages, ['sv']); + expect(profile.mediaReviewsLanguages, ['en']); + }); + + test('parses the July 2026 CSV string shape for language lists (#1488)', () { + final profile = PlexUserProfile.fromJson({ + 'defaultAudioLanguages': 'en,sv', + 'defaultSubtitleLanguages': 'en,sv', + 'mediaReviewsLanguages': 'en', + }); + + expect(profile.defaultAudioLanguages, ['en', 'sv']); + expect(profile.defaultSubtitleLanguages, ['en', 'sv']); + expect(profile.mediaReviewsLanguages, ['en']); + }); + + test('parses absent and null language lists as null', () { + final profile = PlexUserProfile.fromJson({'defaultAudioLanguages': null}); + + expect(profile.defaultAudioLanguages, isNull); + expect(profile.defaultSubtitleLanguages, isNull); + expect(profile.mediaReviewsLanguages, isNull); + }); + + test('unwraps the profile envelope and tolerates a non-map envelope', () { + final flat = PlexUserProfile.fromJson({'defaultAudioLanguage': 'en'}); + final wrapped = PlexUserProfile.fromJson({ + 'profile': {'defaultAudioLanguage': 'en'}, + }); + final garbage = PlexUserProfile.fromJson({'profile': 'garbage'}); + + expect(flat.defaultAudioLanguage, 'en'); + expect(wrapped.defaultAudioLanguage, 'en'); + expect(garbage.defaultAudioLanguage, isNull); + }); + + test('singular language fields take the first entry of array/CSV drift', () { + final csv = PlexUserProfile.fromJson({'defaultAudioLanguage': 'en,sv'}); + final array = PlexUserProfile.fromJson({ + 'defaultSubtitleLanguage': ['sv', 'en'], + }); + + expect(csv.defaultAudioLanguage, 'en'); + expect(array.defaultSubtitleLanguage, 'sv'); + }); + + test('scalar fields coerce from drifted types', () { + final profile = PlexUserProfile.fromJson({ + 'autoSelectAudio': 0, + 'autoSelectSubtitle': '1', + 'watchedIndicator': '2', + 'defaultSubtitleForced': {}, + }); + + expect(profile.autoSelectAudio, isFalse); + expect(profile.autoSelectSubtitle, 1); + expect(profile.watchedIndicator, 2); + expect(profile.defaultSubtitleForced, 1); + }); + + test('defaults() matches parsing an empty map', () { + final parsed = PlexUserProfile.fromJson(const {}); + final defaults = PlexUserProfile.defaults(); + + expect(defaults.autoSelectAudio, parsed.autoSelectAudio); + expect(defaults.defaultAudioAccessibility, parsed.defaultAudioAccessibility); + expect(defaults.defaultAudioLanguage, parsed.defaultAudioLanguage); + expect(defaults.defaultAudioLanguages, parsed.defaultAudioLanguages); + expect(defaults.defaultSubtitleLanguage, parsed.defaultSubtitleLanguage); + expect(defaults.defaultSubtitleLanguages, parsed.defaultSubtitleLanguages); + expect(defaults.autoSelectSubtitle, parsed.autoSelectSubtitle); + expect(defaults.defaultSubtitleAccessibility, parsed.defaultSubtitleAccessibility); + expect(defaults.defaultSubtitleForced, parsed.defaultSubtitleForced); + expect(defaults.watchedIndicator, parsed.watchedIndicator); + expect(defaults.mediaReviewsVisibility, parsed.mediaReviewsVisibility); + expect(defaults.mediaReviewsLanguages, parsed.mediaReviewsLanguages); + }); + }); +} diff --git a/test/models/user_switch_response_test.dart b/test/models/user_switch_response_test.dart new file mode 100644 index 00000000..1d0aa50b --- /dev/null +++ b/test/models/user_switch_response_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/user_switch_response.dart'; + +/// A realistic `/api/v2/home/users/{uuid}/switch` 201 body using the +/// July 2026 wire shape where profile language lists are CSV strings (#1488). +Map driftedSwitchJson() => { + 'id': 312174832, + 'uuid': 'e443d57860076fc3', + 'username': 'pl1624', + 'title': 'pl1624', + 'email': 'user@example.com', + 'friendlyName': '', + 'locale': null, + 'confirmed': true, + 'joinedAt': 1703877982, + 'emailOnlyAuth': false, + 'hasPassword': true, + 'protected': true, + 'thumb': 'https://plex.tv/users/e443d57860076fc3/avatar', + 'authToken': 'minted-user-token', + 'mailingListActive': false, + 'scrobbleTypes': '', + 'country': 'SE', + 'restricted': false, + 'anonymous': false, + 'home': true, + 'guest': false, + 'homeSize': 2, + 'homeAdmin': true, + 'maxHomeSize': 15, + 'profile': { + 'autoSelectAudio': true, + 'defaultAudioAccessibility': 0, + 'defaultAudioLanguage': 'en', + 'defaultAudioLanguages': 'en,sv', + 'defaultSubtitleLanguage': 'en', + 'defaultSubtitleLanguages': 'en,sv', + 'autoSelectSubtitle': 1, + 'defaultSubtitleAccessibility': 0, + 'defaultSubtitleForced': 1, + 'watchedIndicator': 1, + 'mediaReviewsVisibility': 0, + 'mediaReviewsLanguages': null, + 'mediaPostsVisibility': true, + }, + 'twoFactorEnabled': false, + 'backupCodesCreated': false, + 'attributionPartner': null, +}; + +void main() { + group('UserSwitchResponse.fromJson', () { + test('parses a realistic drifted 201 body, preserving the token', () { + final response = UserSwitchResponse.fromJson(driftedSwitchJson()); + + expect(response.authToken, 'minted-user-token'); + expect(response.uuid, 'e443d57860076fc3'); + expect(response.protected, isTrue); + expect(response.homeAdmin, isTrue); + expect(response.profile.defaultAudioLanguages, ['en', 'sv']); + expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + }); + + test('parses a token-only body with defaults everywhere else', () { + final response = UserSwitchResponse.fromJson({'authToken': 'tok'}); + + expect(response.authToken, 'tok'); + expect(response.id, 0); + expect(response.uuid, ''); + expect(response.title, ''); + expect(response.confirmed, isFalse); + expect(response.homeSize, 1); + expect(response.maxHomeSize, 1); + expect(response.profile.autoSelectAudio, isTrue); + expect(response.profile.defaultAudioLanguages, isNull); + }); + + test('never loses the token to wrong-typed decorative fields', () { + final response = UserSwitchResponse.fromJson({ + 'authToken': 'tok', + 'id': {}, + 'uuid': 42, + 'title': 7, + 'confirmed': 'yes', + 'joinedAt': {}, + 'hasPassword': 'nope', + 'protected': [], + 'thumb': 1.5, + 'homeSize': 'many', + 'maxHomeSize': null, + 'profile': 'garbage', + 'twoFactorEnabled': {}, + }); + + expect(response.authToken, 'tok'); + expect(response.id, 0); + expect(response.uuid, '42'); + expect(response.title, '7'); + expect(response.confirmed, isFalse); + expect(response.homeSize, 1); + expect(response.profile.autoSelectAudio, isTrue); + expect(response.profile.defaultAudioLanguages, isNull); + }); + + test('throws when authToken is missing, empty, or not a string', () { + expect(() => UserSwitchResponse.fromJson(const {}), throwsFormatException); + expect(() => UserSwitchResponse.fromJson({'authToken': ''}), throwsFormatException); + expect(() => UserSwitchResponse.fromJson({'authToken': 12345}), throwsFormatException); + }); + }); +} diff --git a/test/profiles/plex_home_switch_test.dart b/test/profiles/plex_home_switch_test.dart new file mode 100644 index 00000000..289b8a1d --- /dev/null +++ b/test/profiles/plex_home_switch_test.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/profiles/plex_home_switch.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +/// Regression for #1488: Plex started returning profile language lists as CSV +/// strings ("en,sv") instead of arrays; the parse crash on the 201 /switch +/// response made the flow report failure and drop the freshly minted token, +/// leaving the app permanently offline. +void main() { + setUpAll(() { + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + PlexAuthService authReturning(MockClient mock) { + final client = MediaServerHttpClient(client: mock); + addTearDown(client.close); + return PlexAuthService.forTesting(http: client); + } + + group('switchPlexHomeUserWithPin', () { + test('succeeds and keeps the token when the 201 body has drifted field shapes', () async { + final auth = authReturning( + MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/v2/home/users/uuid-1/switch'); + return http.Response(jsonEncode(_driftedSwitchBody()), 201, headers: {'content-type': 'application/json'}); + }), + ); + + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: 'account-token', + homeUserUuid: 'uuid-1', + requiresPin: false, + promptForPin: ({String? errorMessage}) async => fail('should not prompt'), + ); + + expect(result.status, PlexHomeSwitchStatus.success); + expect(result.userToken, 'minted-user-token'); + }); + + test('re-prompts on Plex error 1041 and succeeds with the corrected PIN', () async { + var attempts = 0; + final auth = authReturning( + MockClient((request) async { + attempts++; + if (attempts == 1) { + return http.Response( + jsonEncode({ + 'errors': [ + {'code': 1041, 'message': 'Invalid PIN', 'status': 403}, + ], + }), + 403, + headers: {'content-type': 'application/json'}, + ); + } + expect(request.url.queryParameters['pin'], '1234'); + return http.Response(jsonEncode(_driftedSwitchBody()), 201, headers: {'content-type': 'application/json'}); + }), + ); + + final promptErrors = []; + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: 'account-token', + homeUserUuid: 'uuid-1', + requiresPin: false, + promptForPin: ({String? errorMessage}) async { + promptErrors.add(errorMessage); + return '1234'; + }, + ); + + expect(result.status, PlexHomeSwitchStatus.success); + expect(result.userToken, 'minted-user-token'); + expect(attempts, 2); + expect(promptErrors, hasLength(1)); + expect(promptErrors.single, isNotNull); + }); + + test('reports cancelled when the user dismisses the PIN prompt', () async { + final auth = authReturning( + MockClient((request) async { + return http.Response( + jsonEncode({ + 'errors': [ + {'code': 1041, 'message': 'Invalid PIN', 'status': 403}, + ], + }), + 403, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: 'account-token', + homeUserUuid: 'uuid-1', + requiresPin: false, + promptForPin: ({String? errorMessage}) async => null, + ); + + expect(result.status, PlexHomeSwitchStatus.cancelled); + expect(result.userToken, isNull); + }); + + test('reports failed when the response has no token', () async { + final auth = authReturning( + MockClient((request) async { + return http.Response( + jsonEncode({'id': 1, 'uuid': 'uuid-1'}), + 201, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: 'account-token', + homeUserUuid: 'uuid-1', + requiresPin: false, + promptForPin: ({String? errorMessage}) async => fail('should not prompt'), + ); + + expect(result.status, PlexHomeSwitchStatus.failed); + expect(result.userToken, isNull); + }); + }); +} + +Map _driftedSwitchBody() => { + 'id': 312174832, + 'uuid': 'uuid-1', + 'title': 'hi_phi', + 'authToken': 'minted-user-token', + 'protected': true, + 'home': true, + 'profile': { + 'defaultAudioLanguage': 'en', + 'defaultAudioLanguages': 'en,sv', + 'defaultSubtitleLanguage': 'en', + 'defaultSubtitleLanguages': 'en,sv', + 'mediaReviewsLanguages': null, + 'mediaPostsVisibility': true, + }, +}; diff --git a/test/services/plex_auth_service_test.dart b/test/services/plex_auth_service_test.dart index 7a214c1a..cda88442 100644 --- a/test/services/plex_auth_service_test.dart +++ b/test/services/plex_auth_service_test.dart @@ -46,6 +46,39 @@ void main() { ); expect(hosts, ['clients.plex.tv']); }); + + test('switchToUser tolerates drifted field shapes on the 201 body (#1488)', () async { + final client = MediaServerHttpClient( + client: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.host, 'clients.plex.tv'); + expect(request.url.path, '/api/v2/home/users/uuid-1/switch'); + return http.Response( + jsonEncode({ + 'id': 312174832, + 'uuid': 'uuid-1', + 'title': 'hi_phi', + 'authToken': 'minted-user-token', + 'profile': { + 'defaultAudioLanguages': 'en,sv', + 'defaultSubtitleLanguages': 'en,sv', + 'mediaPostsVisibility': true, + }, + }), + 201, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + final auth = PlexAuthService.forTesting(http: client); + + final response = await auth.switchToUser('uuid-1', 'account-token'); + + expect(response.authToken, 'minted-user-token'); + expect(response.profile.defaultAudioLanguages, ['en', 'sv']); + expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + }); }); } diff --git a/test/utils/json_utils_test.dart b/test/utils/json_utils_test.dart index b6d995c3..6d5c15bd 100644 --- a/test/utils/json_utils_test.dart +++ b/test/utils/json_utils_test.dart @@ -177,4 +177,34 @@ void main() { expect(flexibleStringList([1, 2, 3]), isNull); }); }); + + group('flexibleCsvStringList', () { + test('passes a list of strings through', () { + expect(flexibleCsvStringList(['en', 'sv']), ['en', 'sv']); + }); + + test('wraps a bare string in a list', () { + expect(flexibleCsvStringList('en'), ['en']); + }); + + test('splits a CSV string', () { + expect(flexibleCsvStringList('en,sv'), ['en', 'sv']); + }); + + test('trims parts and drops empties', () { + expect(flexibleCsvStringList('en, sv , ,fr'), ['en', 'sv', 'fr']); + expect(flexibleCsvStringList(','), isNull); + expect(flexibleCsvStringList(''), isNull); + }); + + test('splits CSV inside list elements and drops non-strings', () { + expect(flexibleCsvStringList(['en,sv', 'fr']), ['en', 'sv', 'fr']); + expect(flexibleCsvStringList([1, 'en']), ['en']); + }); + + test('returns null for null and empty input', () { + expect(flexibleCsvStringList(null), isNull); + expect(flexibleCsvStringList([]), isNull); + }); + }); }