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:
edde746
2026-07-05 06:23:34 +02:00
parent 6e83aeda88
commit 96510f8aac
9 changed files with 546 additions and 57 deletions
+40 -8
View File
@@ -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;
+14 -21
View File
@@ -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) =>
+52 -28
View File
@@ -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'),
);
}
+19
View File
@@ -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>[];
+92
View File
@@ -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);
});
});
}
+111
View File
@@ -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);
});
});
}
+155
View File
@@ -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,
},
};
+33
View File
@@ -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']);
});
});
}
+30
View File
@@ -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);
});
});
}