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
+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,
},
};