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
This commit is contained in:
@@ -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<String>? defaultAudioLanguages;
|
||||
@JsonKey(fromJson: _flexibleLanguage)
|
||||
@override
|
||||
final String? defaultSubtitleLanguage;
|
||||
@JsonKey(fromJson: flexibleCsvStringList)
|
||||
@override
|
||||
final List<String>? 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<String>? 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<String, dynamic> json) {
|
||||
final profile = json['profile'] as Map<String, dynamic>? ?? json;
|
||||
final envelope = json['profile'];
|
||||
final profile = envelope is Map<String, dynamic> ? envelope : json;
|
||||
return _$PlexUserProfileFromJson(profile);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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;
|
||||
|
||||
@@ -9,27 +9,20 @@ part of 'plex_user_profile.dart';
|
||||
PlexUserProfile _$PlexUserProfileFromJson(
|
||||
Map<String, dynamic> 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<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?,
|
||||
defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List<dynamic>?)
|
||||
?.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<dynamic>?)
|
||||
?.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<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) =>
|
||||
|
||||
@@ -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<String, dynamic> 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'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,25 @@ List<String>? 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<String>?`. 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<String>? 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<String>? stringListFromRaw(Object? raw, {String? mapKey, bool stringify = false, bool nullIfEmpty = false}) {
|
||||
if (raw is! List) return null;
|
||||
final result = <String>[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<String, dynamic> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 = <String?>[];
|
||||
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<String, dynamic> _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,
|
||||
},
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -177,4 +177,34 @@ void main() {
|
||||
expect(flexibleStringList(<dynamic>[1, 2, 3]), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('flexibleCsvStringList', () {
|
||||
test('passes a list of strings through', () {
|
||||
expect(flexibleCsvStringList(<dynamic>['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(<dynamic>['en,sv', 'fr']), ['en', 'sv', 'fr']);
|
||||
expect(flexibleCsvStringList(<dynamic>[1, 'en']), ['en']);
|
||||
});
|
||||
|
||||
test('returns null for null and empty input', () {
|
||||
expect(flexibleCsvStringList(null), isNull);
|
||||
expect(flexibleCsvStringList(<dynamic>[]), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user